From c8f82350aece6b77920b88d1ba226315de87ee5b Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 18 Aug 2026 12:27:30 +0000 Subject: [PATCH 01/63] fix(stack): deflake service-state settling and control endpoint acquisition (#6245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR removes a family of intermittent stack test failures by fixing the underlying races rather than the tests, then verifies the result with five consecutive green CI runs and a local 2× CPU-oversubscribed stress gauntlet (8/8 integration runs green). Runtime fixes: - **`stopService` settling race** (the original `'Dormant' vs 'Stopped'` CI flake): `stopService` returned before the background projection fiber re-published the public state, so an immediate `getState` could observe the previous status. It now settles the projection before returning, matching every start path. - **Control endpoint port collisions**: the control endpoint derived a single loopback port from two bytes of the stack id, so two live stacks could birthday-collide and the later acquirer hard-failed with `ControlAddressConflictError`. `acquireControl`/`probeControl` now walk a short deterministic candidate sequence — attach to a matching owner on any candidate (verified by `ownershipId`), bind the first free one, and conflict only when all candidates are foreign-occupied. `connectManagedStack` uses the probed endpoint, and exact service-port requests reserve every candidate. Protocol mismatch still fails closed. - **Keep-alive livelock**: control status probes reused pooled connections, so a closed listener kept answering `/owner` on the poller's own hot connection and a scan-first acquirer could never bind the freed endpoint. Control reads and stop requests are now one-shot connections in both Node and Bun transports. - **Retry budgets**: the bound-but-not-serving acquire retry is now duration-bounded (a count-based budget stretched a single acquire to 30–45s when reads hit the 500ms transport timeout), and `startStack`'s workspace-repair fence waits up to 30s instead of ~5s, which a realistic Git repair can exceed. Test hardening: - Managed test layers set a new `preferCatalogDefaults: false` plan option so parallel suites stop contending on the default ports (54321…), which sticky reuse re-reserves exactly; production behavior and the spec'd no-relocation semantics are unchanged. - Write-failure injections are privilege-independent (FileSystem-seam gates / directory-as-file instead of chmod, which root bypasses). - Supervisor test watchers re-arm on ENOENT from atomic-write temp files vanishing mid-scan; sub-second synchronization timeouts are widened into guards. ## Linked issue Closes # - [x] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). ## Checklist - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). - [x] Tests added or updated for the change. - [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. https://claude.ai/code/session_01McsNM9yxC5tiY6SusBALoq --------- Co-authored-by: Claude --- packages/stack/docs/architecture.md | 31 ++- packages/stack/src/LocalStack.ts | 3 + packages/stack/src/Stack.unit.test.ts | 28 +-- packages/stack/src/discovery.ts | 2 +- .../src/managed-control.integration.test.ts | 66 +++-- ...aged-manager-lifecycle.integration.test.ts | 6 +- ...naged-manager-projects.integration.test.ts | 20 +- ...naged-manager-recovery.integration.test.ts | 65 +++-- packages/stack/src/managed/control.ts | 227 +++++++++++++----- packages/stack/src/managed/lifecycle.ts | 35 +-- packages/stack/src/managed/manager.ts | 42 +++- packages/stack/src/managed/port-plan.ts | 12 +- packages/stack/src/platform-bun.ts | 5 + packages/stack/src/platform-node.ts | 5 + .../stack/src/supervisor.integration.test.ts | 64 ++++- .../stack/tests/helpers/managed-manager.ts | 2 +- .../stack/tests/helpers/supervisor-child.ts | 20 +- 17 files changed, 443 insertions(+), 190 deletions(-) diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 742cc3bbd6..d89005bcc9 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -101,9 +101,10 @@ runtime logs, and `runtime/` for supervisor-owned runtime files. The `ManagedStackManager` is the only component that writes `stack.json`. Control ownership is the liveness and mutation authority. `acquireControl` -returns `Owned` for the process that bound the deterministic endpoint or -`Attached` for a live owner. An attached caller uses the owner's endpoint for -runtime requests; it never edits the document directly. +returns `Owned` for the process that bound one of the deterministic endpoint +candidates or `Attached` for a live owner found on any candidate. An attached +caller uses the owner's actual endpoint for runtime requests; it never edits +the document directly. ### Start and attach @@ -121,8 +122,8 @@ runtime requests; it never edits the document directly. `stack.start()` over the control transport when service startup is needed. `connectManagedStack` reads the document, probes the deterministic endpoint -without binding it, and returns a `RemoteStack` only when the owner reports a -ready running state. Read-only status and discovery therefore do not claim an +candidates without binding them, and returns a `RemoteStack` against the +owner's actual endpoint only when the owner reports a ready running state. Read-only status and discovery therefore do not claim an endpoint; mutating operations acquire control ownership. ### Update, stop, and delete @@ -200,14 +201,18 @@ persisted endpoint, or another stack's reservation under the normal exact-port rules. A persisted automatic assignment in the control range is invalid and fails loudly rather than being silently migrated. -The control endpoint is derived from the stack id and served on loopback. A -persisted endpoint is accepted only when it matches that derivation. A rare -hash collision or unrelated listener makes control acquisition fail with a -typed conflict; a read-only probe treats the address as non-live and never -claims it. An exact service port can still equal the future endpoint of an -identity that has never started, so that low-probability conflict is rejected -when ownership is acquired rather than forbidding every explicit port in the -reserved range. +The control endpoint is derived from the stack id and served on loopback. The +derivation yields a short deterministic candidate sequence rather than a +single port: an owner binds the first free candidate, skipping candidates +occupied by other stacks or unrelated listeners, and readers scan the same +sequence and match the published `ownershipId`. A hash collision between two +stack ids therefore degrades to the collided stack binding its next candidate +instead of failing. Acquisition fails with a typed conflict only when every +candidate is occupied by a foreign listener; a read-only probe treats an +address with no matching owner as non-live and never claims it. An exact +service port can still equal a candidate of an identity that has never +started, so every stack's full candidate set is reserved against exact-port +requests rather than forbidding every explicit port in the reserved range. This is deliberately a small single-user localhost mechanism. The control protocol has no token authentication; ownership, endpoint identity, and diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 04f7e2884a..8897c679b9 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -793,6 +793,9 @@ export const localStackLayer = ( ).toReversed()) { yield* runtime.orchestrator.stopService(target); } + // Settle the public projection before returning so callers observe + // the stop immediately, matching the start/restart/waitReady paths. + yield* syncRuntimeProjectedStates(runtime); }).pipe(withLifecycleLock), restartService: (name) => Effect.gen(function* () { diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 44196e5dea..1eb8a84283 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -3,7 +3,7 @@ import { BunServices } from "@effect/platform-bun"; import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; import { mkdtempSync } from "node:fs"; -import { chmod, readFile, rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; @@ -247,13 +247,18 @@ describe("Stack", () => { ).toBe("StackBuildError"); expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + // Replace the workspace directory with a plain file so the config write + // fails for any user — permission bits alone are bypassed by root. const runtimeDirectory = join(runtimeRoot, "edge-runtime"); - yield* Effect.promise(() => chmod(runtimeDirectory, 0o500)); + yield* Effect.promise(async () => { + await rm(runtimeDirectory, { recursive: true, force: true }); + await writeFile(runtimeDirectory, ""); + }); const failedBundle = functionsBundle(runtimeRoot, "failed-secret"); const error = yield* stack.reloadFunctions({ functions: failedBundle }).pipe(Effect.flip); expect(error._tag).toBe("StackBuildError"); - yield* Effect.promise(() => chmod(runtimeDirectory, 0o700)); + yield* Effect.promise(() => rm(runtimeDirectory)); yield* stack.reloadFunctions(); expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); @@ -268,12 +273,7 @@ describe("Stack", () => { ).toBe(false); }).pipe( Effect.provide(layer), - Effect.ensuring( - Effect.promise(async () => { - await chmod(join(runtimeRoot, "edge-runtime"), 0o700).catch(() => {}); - await rm(runtimeRoot, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), Effect.timeout("5 seconds"), ); }); @@ -1087,14 +1087,10 @@ describe("Stack", () => { return Effect.gen(function* () { const stack = yield* Stack; yield* stack.start(); - const authChanges = yield* stack.stateChanges("auth"); - const stopped = yield* authChanges.pipe( - Stream.filter((state) => state.status === "Stopped"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); + // `stopService` settles the public projection before returning, so the + // stopped state is observable immediately without stream coordination. yield* stack.stopService("auth"); - expect((yield* Fiber.join(stopped))._tag).toBe("Some"); + expect((yield* stack.getState("auth")).status).toBe("Stopped"); yield* stack.stop(); yield* stack.start(); diff --git a/packages/stack/src/discovery.ts b/packages/stack/src/discovery.ts index cf21f60d15..cddeec124e 100644 --- a/packages/stack/src/discovery.ts +++ b/packages/stack/src/discovery.ts @@ -74,7 +74,7 @@ const liveStatus = ( ): Effect.Effect => manager .probeControl(document.id) - .pipe(Effect.map((status) => status?.state === "running" && status.ready)); + .pipe(Effect.map((probe) => probe?.status.state === "running" && probe.status.ready)); export const listStacks = (opts: { readonly cacheRoot: string; diff --git a/packages/stack/src/managed-control.integration.test.ts b/packages/stack/src/managed-control.integration.test.ts index 0dbd8fe5e2..7fd41e05b7 100644 --- a/packages/stack/src/managed-control.integration.test.ts +++ b/packages/stack/src/managed-control.integration.test.ts @@ -7,10 +7,13 @@ import { describe, expect } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; import { acquireControl, + CONTROL_CANDIDATE_COUNT, controlEndpoint, + controlEndpointCandidates, ControlBindError, ControlTransport, ControlTransportError, + probeControl, } from "./managed/control.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; @@ -103,10 +106,15 @@ const spawnBoundChild = (port: number) => { }; describe("managed control endpoint", () => { - it.live("derives one deterministic loopback endpoint from the ownership id", () => { + it.live("derives deterministic loopback candidates from the ownership id", () => { return Effect.sync(() => { const endpoint = Effect.runSync(controlEndpoint(STACK_ID)); expect(endpoint.url).toBe("http://127.0.0.1:13737"); + const candidates = Effect.runSync(controlEndpointCandidates(STACK_ID)); + expect(candidates).toHaveLength(CONTROL_CANDIDATE_COUNT); + expect(candidates.map(({ port }) => port)).toEqual( + Array.from({ length: CONTROL_CANDIDATE_COUNT }, (_, offset) => 13737 + offset), + ); }); }); @@ -254,7 +262,7 @@ describe("managed control endpoint", () => { ), ); - it.live("rejects a valid owner with a colliding deterministic endpoint", () => + it.live("claims the next candidate when another stack owns the first", () => Effect.scoped( live( Effect.gen(function* () { @@ -272,15 +280,20 @@ describe("managed control endpoint", () => { ), ); yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); - const contender = yield* acquireControl({ stackId: COLLIDING_STACK_ID }).pipe( - Effect.exit, - ); - expect(Exit.isFailure(contender)).toBe(true); - if (Exit.isFailure(contender)) { - expect(Cause.squash(contender.cause)).toMatchObject({ - _tag: "ControlAddressConflictError", - }); - } + const contender = yield* acquireControl({ stackId: COLLIDING_STACK_ID }); + if (contender._tag !== "Owned") throw new Error("expected contender ownership"); + expect(contender.endpoint.port).not.toBe(owner.endpoint.port); + + // Readers locate each owner at its actual candidate. + const ownerProbe = yield* probeControl(STACK_ID); + expect(ownerProbe?.endpoint.port).toBe(owner.endpoint.port); + const contenderProbe = yield* probeControl(COLLIDING_STACK_ID); + expect(contenderProbe?.endpoint.port).toBe(contender.endpoint.port); + + // A second caller for the collided stack attaches to its owner. + const attached = yield* acquireControl({ stackId: COLLIDING_STACK_ID }); + expect(attached._tag).toBe("Attached"); + expect(attached.endpoint.port).toBe(contender.endpoint.port); yield* Effect.promise(() => daemonRuntime.dispose()); }), ), @@ -306,15 +319,37 @@ describe("managed control endpoint", () => { ), ); - it.live("rejects an unrelated listener without taking it over", () => + it.live("claims the next candidate without taking over an unrelated listener", () => live( Effect.scoped( Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); + const candidates = yield* controlEndpointCandidates(STACK_ID); const unrelated = yield* Effect.acquireRelease( - Effect.promise(() => listenRaw(endpoint.port)), + Effect.promise(() => listenRaw(candidates[0]!.port)), (server) => Effect.promise(() => closeRaw(server)), ); + const owner = yield* acquireControl({ stackId: STACK_ID }); + if (owner._tag !== "Owned") throw new Error("expected control ownership"); + expect(owner.endpoint.port).toBe(candidates[1]!.port); + expect(unrelated.listening).toBe(true); + const probe = yield* probeControl(STACK_ID); + expect(probe?.endpoint.port).toBe(candidates[1]!.port); + }), + ), + ), + ); + + it.live("fails once every candidate is occupied by unrelated listeners", () => + live( + Effect.scoped( + Effect.gen(function* () { + const candidates = yield* controlEndpointCandidates(STACK_ID); + yield* Effect.forEach(candidates, (candidate) => + Effect.acquireRelease( + Effect.promise(() => listenRaw(candidate.port)), + (server) => Effect.promise(() => closeRaw(server)), + ), + ); const result = yield* acquireControl({ stackId: STACK_ID, }).pipe( @@ -325,7 +360,6 @@ describe("managed control endpoint", () => { ); expect(result._tag).toBe("Left"); if (result._tag === "Left") expect(result.error._tag).toBe("ControlAddressConflictError"); - expect(unrelated.listening).toBe(true); }), ), ), @@ -350,7 +384,7 @@ describe("managed control endpoint", () => { requestStop: () => Effect.void, }); const exit = yield* acquireControl({ stackId: STACK_ID }).pipe( - Effect.timeout("2 seconds"), + Effect.timeout("10 seconds"), Effect.exit, Effect.provide(unavailable), ); diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index f75f0df0d0..dcba0d8407 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -133,7 +133,9 @@ describe("managed stack lifecycle journeys", () => { ? Effect.succeed(current) : Effect.fail(new Error("stop pending")), ), - Effect.retry(Schedule.spaced("10 millis").pipe(Schedule.upTo({ duration: "2 seconds" }))), + Effect.retry( + Schedule.spaced("10 millis").pipe(Schedule.upTo({ duration: "10 seconds" })), + ), ); yield* owner.close; yield* Fiber.join(stopFiber); @@ -179,7 +181,7 @@ describe("managed stack lifecycle journeys", () => { } satisfies FileSystem.FileSystem; }), ).pipe(Layer.provide(NodeFileSystem.layer)); - const managerLayer = managedStackManagerLayer({ stateRoot }).pipe( + const managerLayer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( Layer.provide(gatedFileSystemLayer), Layer.provide(NodePath.layer), Layer.provide(gitConfigStoreLayer), diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index d88a09795f..1ff6a4013a 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -151,17 +151,23 @@ describe("managed stack projects journeys", () => { const baseTransport = yield* ControlTransport; const readStarted = yield* Deferred.make(); const continueRead = yield* Deferred.make(); + // Hold only the status probe's first read in flight. Later reads (the + // concurrent acquire scans its endpoint candidates before binding) must + // pass through, mirroring the real transport's bounded read timeout. let gateReads = false; + let gatedRead = false; const gatedTransport = Layer.succeed(ControlTransport, { ...baseTransport, read: (endpoint) => - gateReads - ? Effect.gen(function* () { - yield* Deferred.succeed(readStarted, void 0); - yield* Deferred.await(continueRead); - return yield* baseTransport.read(endpoint); - }) - : baseTransport.read(endpoint), + Effect.suspend(() => { + if (!gateReads || gatedRead) return baseTransport.read(endpoint); + gatedRead = true; + return Effect.gen(function* () { + yield* Deferred.succeed(readStarted, void 0); + yield* Deferred.await(continueRead); + return yield* baseTransport.read(endpoint); + }); + }), }); yield* Effect.scoped( diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index 2bad6395ad..9ccfffc7e6 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -1,17 +1,19 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + ManagedRuntime, + PlatformError, +} from "effect"; import { HttpServer } from "effect/unstable/http"; import { randomBytes } from "node:crypto"; -import { - chmodSync, - cpSync, - mkdirSync, - mkdtempSync, - realpathSync, - renameSync, - writeFileSync, -} from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, realpathSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; @@ -50,7 +52,7 @@ const acquireIsolatedCollisionOwner = () => const stackId = randomBytes(32).toString("hex"); const collidingStackId = `${stackId.slice(0, 10)}${randomBytes(27).toString("hex")}`; const acquisition = yield* acquireControl({ stackId }).pipe( - Effect.timeout("1 second"), + Effect.timeout("5 seconds"), Effect.exit, ); if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { @@ -67,7 +69,7 @@ const acquireIsolatedStackOwner = (workspacePath: string) => const stackName = `test-${randomBytes(8).toString("hex")}`; const stackId = deriveStackId(environment.identity, stackName); const acquisition = yield* acquireControl({ stackId }).pipe( - Effect.timeout("1 second"), + Effect.timeout("5 seconds"), Effect.exit, ); if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { @@ -126,7 +128,7 @@ describe("managed stack recovery journeys", () => { } satisfies FileSystem.FileSystem; }), ).pipe(Layer.provide(NodeFileSystem.layer)); - const managerLayer = managedStackManagerLayer({ stateRoot }).pipe( + const managerLayer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( Layer.provide(gatedFileSystemLayer), Layer.provide(NodePath.layer), Layer.provide(gitConfigStoreLayer), @@ -222,11 +224,11 @@ describe("managed stack recovery journeys", () => { ownership: stackOwner.ownership, }) .pipe(Effect.forkScoped); - yield* Deferred.await(repairRead).pipe(Effect.timeout("1 second")); + yield* Deferred.await(repairRead).pipe(Effect.timeout("30 seconds")); expect(yield* manager.inspectStack(stackId)).toBeUndefined(); yield* repairOwner.close; yield* Effect.promise(() => repairDaemon.dispose()); - const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("2 seconds")); + const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("60 seconds")); expect(started.stack.id).toBe(stackId); yield* releaseLease(started); }), @@ -270,6 +272,33 @@ describe("managed stack recovery journeys", () => { it.live("repairs a moved workspace without changing stack id or ports", () => { const { layer, stateRoot } = setup(); + // Permission bits cannot block writes when tests run as root, so gate the + // FileSystem seam instead to force the partial-repair failure. + const blockedWrites = { root: undefined as string | undefined }; + const blockingFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem.FileSystem; + return { + ...base, + writeFileString: ( + path: string, + data: string, + options?: Parameters[2], + ) => + blockedWrites.root !== undefined && path.startsWith(blockedWrites.root) + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + pathOrDescriptor: path, + }), + ) + : base.writeFileString(path, data, options), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeFileSystem.layer)); return Effect.scoped( Effect.gen(function* () { const root = mkdtempSync(join(tmpdir(), "managed-repair-test-")); @@ -322,9 +351,9 @@ describe("managed stack recovery journeys", () => { const blockedId = [originalId, secondaryId].sort().at(-1); if (blockedId === undefined) throw new Error("expected affected stack"); const blockedRoot = managedStackPaths(stateRoot, blockedId).root; - chmodSync(blockedRoot, 0o500); + blockedWrites.root = blockedRoot; const failed = yield* manager.repairWorkspace(discovery.repair).pipe(Effect.exit); - chmodSync(blockedRoot, 0o700); + blockedWrites.root = undefined; expect(Exit.isFailure(failed)).toBe(true); const firstUpdatedId = [originalId, secondaryId].sort().at(0); if (firstUpdatedId === undefined) throw new Error("expected affected stack"); @@ -349,7 +378,7 @@ describe("managed stack recovery journeys", () => { }), ).pipe( Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), + Effect.provide(blockingFileSystemLayer), Effect.provide(NodePath.layer), Effect.provide(gitConfigStoreLayer), Effect.provide(controlTransportLayer), diff --git a/packages/stack/src/managed/control.ts b/packages/stack/src/managed/control.ts index 122b92dbff..65efbdab0b 100644 --- a/packages/stack/src/managed/control.ts +++ b/packages/stack/src/managed/control.ts @@ -69,7 +69,7 @@ export class ControlAddressConflictError extends Data.TaggedError("ControlAddres readonly cause: unknown; }> { override get message(): string { - return `Control endpoint ${this.endpoint.url} is occupied by a non-Supabase listener`; + return `Control endpoint ${this.endpoint.url} is occupied by another listener`; } } @@ -148,20 +148,41 @@ const ownershipBytes = (ownershipId: string): ReadonlyArray => { return bytes; }; -/** Derives a deterministic loopback address and reserved port from a stack id. */ -export const controlEndpoint = ( +/** + * Number of deterministic endpoints derived per ownership id. Two ids can + * hash to the same primary port, so owners fall through to the next + * candidate and readers scan the same sequence, matching on `ownershipId`. + */ +export const CONTROL_CANDIDATE_COUNT = 8; + +const CONTROL_RANGE_SIZE = CONTROL_PORT_RANGE.max - CONTROL_PORT_RANGE.min + 1; + +const endpointForValue = (value: number): ControlEndpoint => { + const port = CONTROL_PORT_RANGE.min + (value % CONTROL_RANGE_SIZE); + const host = "127.0.0.1"; + return { hostname: host, port, url: `http://${host}:${port}` }; +}; + +/** Derives the deterministic loopback endpoint candidates for a stack id. */ +export const controlEndpointCandidates = ( ownershipId: string, -): Effect.Effect => { +): Effect.Effect, InvalidControlOwnershipIdError> => { if (!CONTROL_ID_PATTERN.test(ownershipId)) return invalidId(ownershipId); const bytes = ownershipBytes(ownershipId); const value = (bytes[3]! << 8) | bytes[4]!; - const port = - CONTROL_PORT_RANGE.min + (value % (CONTROL_PORT_RANGE.max - CONTROL_PORT_RANGE.min + 1)); - const host = "127.0.0.1"; - const url = `http://${host}:${port}`; - return Effect.succeed({ hostname: host, port, url }); + return Effect.succeed( + Array.from({ length: CONTROL_CANDIDATE_COUNT }, (_, offset) => + endpointForValue(value + offset), + ), + ); }; +/** Derives the primary deterministic endpoint (first candidate) for a stack id. */ +export const controlEndpoint = ( + ownershipId: string, +): Effect.Effect => + Effect.map(controlEndpointCandidates(ownershipId), (candidates) => candidates[0]!); + const decodeOwnerStatus = ( endpoint: ControlEndpoint, value: unknown, @@ -224,22 +245,40 @@ const readOwnerStatus = ( ), ); -/** Reads an existing owner without ever claiming its deterministic endpoint. */ +/** A located owner: its published status and the candidate it bound. */ +export interface ControlProbe { + readonly status: ControlOwnerStatus; + readonly endpoint: ControlEndpoint; +} + +/** Reads an existing owner wherever it bound, without claiming an endpoint. */ export const probeControl = ( ownershipId: string, -): Effect.Effect< - ControlOwnerStatus | undefined, - InvalidControlOwnershipIdError, - ControlTransport -> => +): Effect.Effect => Effect.gen(function* () { - const endpoint = yield* controlEndpoint(ownershipId); + const candidates = yield* controlEndpointCandidates(ownershipId); const transport = yield* ControlTransport; - return yield* readOwnerStatus(endpoint, ownershipId, transport).pipe( - Effect.catch(() => Effect.succeed(undefined)), - ); + for (const endpoint of candidates) { + const status = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ); + if (status !== undefined) return { status, endpoint }; + } + return undefined; }); +const makeAttached = ( + endpoint: ControlEndpoint, + ownershipId: string, + transport: ControlTransportShape, +): ControlAttached => ({ + _tag: "Attached", + ownershipId, + endpoint, + ownerStatus: readOwnerStatus(endpoint, ownershipId, transport), + requestStop: transport.requestStop(endpoint), +}); + const attach = ( endpoint: ControlEndpoint, ownershipId: string, @@ -252,13 +291,7 @@ const attach = ( | ControlAddressConflictError > => readOwnerStatus(endpoint, ownershipId, transport).pipe( - Effect.map(() => ({ - _tag: "Attached" as const, - ownershipId, - endpoint, - ownerStatus: readOwnerStatus(endpoint, ownershipId, transport), - requestStop: transport.requestStop(endpoint), - })), + Effect.map(() => makeAttached(endpoint, ownershipId, transport)), ); const makeOwned = ( @@ -295,8 +328,33 @@ const makeOwned = ( }); }; -const acquireAtEndpoint = ( - endpoint: ControlEndpoint, +/** + * Finds the candidate a live owner of `ownershipId` bound, if any. Foreign + * owners, non-Supabase listeners, and free ports are skipped; a protocol + * mismatch fails closed because a newer owner of this very stack may be + * publishing there, and claiming another candidate beside it would split + * ownership across versions. + */ +const scanForOwner = ( + candidates: ReadonlyArray, + ownershipId: string, + transport: ControlTransportShape, +): Effect.Effect => + Effect.gen(function* () { + for (const endpoint of candidates) { + const found = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe( + Effect.map(() => true), + Effect.catchTag("ControlTransportError", () => Effect.succeed(false)), + Effect.catchTag("ControlProtocolError", () => Effect.succeed(false)), + Effect.catchTag("ControlAddressConflictError", () => Effect.succeed(false)), + ); + if (found) return endpoint; + } + return undefined; + }); + +const acquireAtCandidates = ( + candidates: ReadonlyArray, ownershipId: string, status: ControlOwnerStatus, transport: ControlTransportShape, @@ -321,53 +379,98 @@ const acquireAtEndpoint = ( | ControlUnavailableError, import("effect/Scope").Scope > = Effect.gen(function* () { - const bound = yield* transport - .bind( - endpoint, - () => Ref.getUnsafe(statusRef), - () => { - Effect.runSync(Deferred.succeed(stopRequested, void 0)); - }, - ) - .pipe(Effect.result); - if (Result.isSuccess(bound)) { - const owned = yield* makeOwned( + // An existing owner may hold any candidate: an earlier occupant can have + // freed a lower port since the owner bound. Attach before claiming one so + // a stack never ends up with two owners on different candidates. The scan + // read doubles as the attach handshake, so an owner is read exactly once. + const ownerEndpoint = yield* scanForOwner(candidates, ownershipId, transport); + if (ownerEndpoint !== undefined) { + return makeAttached(ownerEndpoint, ownershipId, transport); + } + let pending: ControlUnavailableError | undefined; + let conflict: ControlAddressConflictError | undefined; + for (const endpoint of candidates) { + const bound = yield* transport + .bind( + endpoint, + () => Ref.getUnsafe(statusRef), + () => { + Effect.runSync(Deferred.succeed(stopRequested, void 0)); + }, + ) + .pipe(Effect.result); + if (Result.isSuccess(bound)) { + const owned = yield* makeOwned( + endpoint, + ownershipId, + bound.success, + statusRef, + stopRequested, + ); + yield* Effect.addFinalizer(() => owned.close); + return owned; + } + const error = bound.failure; + if (error.reason !== "in-use") return yield* Effect.fail(error); + // The address was taken between the scan and the bind: attach if the + // occupant is our owner, retry the walk if it is not serving yet, and + // move to the next candidate if it belongs to someone else. + const attached: ControlAcquisition | undefined = yield* attach( endpoint, ownershipId, - bound.success, - statusRef, - stopRequested, + transport, + ).pipe( + Effect.map((acquisition): ControlAcquisition | undefined => acquisition), + Effect.catchTag("ControlAddressConflictError", (cause) => + Effect.sync(() => { + conflict = cause; + return undefined; + }), + ), + Effect.catchTag("ControlProtocolError", (cause) => + Effect.sync(() => { + conflict = new ControlAddressConflictError({ endpoint, cause }); + return undefined; + }), + ), + Effect.catchTag("ControlTransportError", (cause) => + cause.reason === "unreachable" + ? Effect.sync(() => { + pending = unavailable(endpoint, cause); + return undefined; + }) + : Effect.fail(cause), + ), ); - yield* Effect.addFinalizer(() => owned.close); - return owned; + if (attached !== undefined) return attached; } - const error = bound.failure; - if (error.reason !== "in-use") return yield* Effect.fail(error); - return yield* attach(endpoint, ownershipId, transport).pipe( - Effect.mapError((cause) => - cause._tag === "ControlTransportError" && cause.reason === "unreachable" - ? unavailable(endpoint, cause) - : cause._tag === "ControlProtocolError" - ? new ControlAddressConflictError({ endpoint, cause }) - : cause, - ), + if (pending !== undefined) return yield* Effect.fail(pending); + return yield* Effect.fail( + conflict ?? + new ControlAddressConflictError({ + endpoint: candidates[0]!, + cause: new Error("Every control endpoint candidate is occupied"), + }), ); }); return attempt.pipe( Effect.retry({ - // Leave explicit margin inside the parent's 35-second startup handshake, - // even when every owner probe consumes its 500 ms transport timeout. - schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ times: 30 })), + // Bound by duration, not attempts: an attempt's own reads can each + // consume the 500 ms transport timeout, and a count-based budget would + // stretch a single acquire far past the parent's startup handshake. + schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "1500 millis" })), while: (error) => error._tag === "ControlUnavailableError", }), Effect.catchTag("ControlUnavailableError", (error) => - Effect.fail(new ControlAddressConflictError({ endpoint, cause: error.cause })), + Effect.fail( + new ControlAddressConflictError({ endpoint: error.endpoint, cause: error.cause }), + ), ), ); }; -/** Acquires the deterministic loopback listener or attaches to its owner. */ +/** Acquires a deterministic loopback listener or attaches to its owner. */ export const acquireControl = ( input: ControlOwnershipInput, ): Effect.Effect< @@ -381,10 +484,10 @@ export const acquireControl = ( ControlTransport | import("effect/Scope").Scope > => Effect.gen(function* () { - const endpoint = yield* controlEndpoint(input.stackId); + const candidates = yield* controlEndpointCandidates(input.stackId); const transport = yield* ControlTransport; - return yield* acquireAtEndpoint( - endpoint, + return yield* acquireAtCandidates( + candidates, input.stackId, defaultStatus(input.stackId, input.initialStatus), transport, diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index 5387a01b25..fc77333617 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -9,14 +9,13 @@ import { HttpTransportClient, HttpTransportClientError } from "../HttpTransportC import type { ManagedStackDocument } from "./document.ts"; import { ManagedStackAttachedError, - ManagedStackControlRequiredError, ManagedStackManager, ManagedWorkspaceRepairConflictError, workspaceRepairConflict, type ManagedStackManagerError, type ManagedStackLaunchUpdate, } from "./manager.ts"; -import { ControlTransportError, controlEndpoint, type ControlEndpoint } from "./control.ts"; +import { ControlTransportError } from "./control.ts"; import { ManagedStackNotStoppedError, type ManagedPortIntentDocument, @@ -71,28 +70,11 @@ export const resolveManagedDocument = ( return document === undefined ? yield* Effect.fail(noRunningStack(input)) : document; }); -const runtimeEndpoint = ( - document: ManagedStackDocument, - input: ManagedLifecycleInput, -): Effect.Effect => - Effect.gen(function* () { - if ( - (document.lifecycle !== "running" && document.lifecycle !== "starting") || - (document.lifecycle === "running" && document.runtime?.controlEndpoint === undefined) - ) { - return yield* Effect.fail(noRunningStack(input)); - } - const endpoint = yield* controlEndpoint(document.id).pipe( - Effect.mapError(() => new ManagedStackControlRequiredError({ stackId: document.id })), - ); - return endpoint; - }); - class ManagedStopPending extends Data.TaggedError("ManagedStopPending")<{}> {} class ManagedStopOwnerTerminal extends Data.TaggedError("ManagedStopOwnerTerminal")<{}> {} class ManagedDeletePending extends Data.TaggedError("ManagedDeletePending")<{}> {} -/** Connect to the deterministic endpoint persisted by the managed supervisor. */ +/** Connect to the control endpoint the managed supervisor actually bound. */ export const connectManagedStack = ( input: ManagedLifecycleInput, ): Effect.Effect< @@ -102,14 +84,19 @@ export const connectManagedStack = ( > => Effect.gen(function* () { const document = yield* resolveManagedDocument(input); + if ( + (document.lifecycle !== "running" && document.lifecycle !== "starting") || + (document.lifecycle === "running" && document.runtime?.controlEndpoint === undefined) + ) { + return yield* Effect.fail(noRunningStack(input)); + } const manager = yield* ManagedStackManager; - const status = yield* manager.probeControl(document.id); - if (status?.state !== "running" || !status.ready) { + const probe = yield* manager.probeControl(document.id); + if (probe === undefined || probe.status.state !== "running" || !probe.status.ready) { return yield* Effect.fail(noRunningStack(input)); } - const endpoint = yield* runtimeEndpoint(document, input); const client = yield* HttpTransportClient; - return RemoteStack.layer(endpoint).pipe( + return RemoteStack.layer(probe.endpoint).pipe( Layer.provide(Layer.succeed(HttpTransportClient, client)), ); }); diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 0db8766d52..7746b11c8a 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -23,12 +23,12 @@ import { import { acquireControl, CONTROL_PORT_RANGE, - controlEndpoint, + controlEndpointCandidates, ControlTransport, probeControl, type ControlAcquisition, - type ControlOwnerStatus, type ControlOwnership, + type ControlProbe, } from "./control.ts"; import { discoverEnvironment, @@ -209,7 +209,7 @@ export interface ManagedStackManagerShape { ) => Effect.Effect; readonly probeControl: ( stackId: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly readStack: ( request: ReadStackRequest, ) => Effect.Effect; @@ -368,10 +368,17 @@ const conflictError = ( ownerKey: owner.ports.find((candidate) => candidate.port === assignment.port)?.key, }); +interface ManagedStackManagerOptions { + readonly stateRoot: string; + /** Test seam: disable well-known default ports for automatic allocation. */ + readonly preferCatalogDefaults?: boolean; +} + const makeManager = ( - stateRoot: string, + options: ManagedStackManagerOptions, ): Effect.Effect => Effect.gen(function* () { + const { stateRoot, preferCatalogDefaults = true } = options; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const gitConfig = yield* GitConfigStore; @@ -458,6 +465,7 @@ const makeManager = ( disabledFields: request.portDocument.disabledFields, intents: resolvePortIntents(request.portDocument), persisted, + preferCatalogDefaults, }); const requests = portRequests(plan); const exactRequests = requests.filter((item) => item.selection.kind === "exact"); @@ -493,7 +501,9 @@ const makeManager = ( ); } const strictReserved = new Set(); - const exactReserved = new Set([(yield* controlEndpoint(request.stackId)).port]); + const exactReserved = new Set( + (yield* controlEndpointCandidates(request.stackId)).map(({ port }) => port), + ); const owners = new Map< number, ReadonlyArray<{ @@ -502,7 +512,9 @@ const makeManager = ( }> >(); for (const listing of listings.filter(isHealthyDocument)) { - exactReserved.add((yield* controlEndpoint(listing.document.id)).port); + for (const candidate of yield* controlEndpointCandidates(listing.document.id)) { + exactReserved.add(candidate.port); + } if (listing.document.id === request.stackId) continue; for (const assignment of listing.document.ports) { const liveExact = @@ -714,7 +726,12 @@ const makeManager = ( ), ), Effect.retry({ - schedule: Schedule.spaced("20 millis").pipe(Schedule.upTo({ times: 250 })), + // A held repair fence means another process is actively + // repairing this workspace; wait out a realistic repair + // (Git operations included) instead of failing after ~5s. + schedule: Schedule.spaced("20 millis").pipe( + Schedule.upTo({ duration: "30 seconds" }), + ), while: (error) => error instanceof ManagedWorkspaceRepairConflictError, }), ), @@ -999,11 +1016,12 @@ const makeManager = ( }); /** Internal manager layer. Platform layers provide filesystem, Git, and control transport. */ -export const managedStackManagerLayer = (options: { - readonly stateRoot: string; -}): Layer.Layer => - Layer.effect(ManagedStackManager, makeManager(options.stateRoot)); +export const managedStackManagerLayer = ( + options: ManagedStackManagerOptions, +): Layer.Layer => + Layer.effect(ManagedStackManager, makeManager(options)); export const makeManagedStackManager = ( stateRoot: string, -): Effect.Effect => makeManager(stateRoot); +): Effect.Effect => + makeManager({ stateRoot }); diff --git a/packages/stack/src/managed/port-plan.ts b/packages/stack/src/managed/port-plan.ts index 8c78cfb22c..0c249e2a97 100644 --- a/packages/stack/src/managed/port-plan.ts +++ b/packages/stack/src/managed/port-plan.ts @@ -49,6 +49,12 @@ export interface ManagedPortPlanInput { readonly disabledFields?: ReadonlyArray; readonly intents: ReadonlyArray; readonly persisted?: ReadonlyArray; + /** + * Seed automatic selections with the catalog's well-known default ports. + * Tests disable this so parallel suites do not contend on the same + * defaults, which sticky reuse later re-reserves exactly. + */ + readonly preferCatalogDefaults?: boolean; } const automaticSelection = (preferred: number | undefined): PortSelection => @@ -57,6 +63,8 @@ const automaticSelection = (preferred: number | undefined): PortSelection => /** Build sticky durable selections and runtime-only requests from resolved intent. */ export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan => { const persisted = input.persisted ?? []; + const preferredFor = (entry: (typeof PORT_CATALOG)[PortField]): number | undefined => + (input.preferCatalogDefaults ?? true) ? entry.preferred : undefined; const persistedByKey = new Map(persisted.map((assignment) => [assignment.key, assignment])); const intentsByField = new Map(input.intents.map((request) => [request.field, request])); const activeKeys = new Set(); @@ -91,7 +99,7 @@ export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan = field, key: entry.configKey, intent, - selection: automaticSelection(entry.preferred), + selection: automaticSelection(preferredFor(entry)), newlyAllocatedAutomatic: true, }); } @@ -99,7 +107,7 @@ export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan = } if (entry.persistence === "runtime") { - runtimeOnly.push({ field, selection: automaticSelection(entry.preferred) }); + runtimeOnly.push({ field, selection: automaticSelection(preferredFor(entry)) }); } } diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts index dbe2176aef..ebbeee33fc 100644 --- a/packages/stack/src/platform-bun.ts +++ b/packages/stack/src/platform-bun.ts @@ -82,6 +82,10 @@ const controlTransport: ControlTransport["Service"] = { try: async () => { const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, { signal: AbortSignal.timeout(500), + // One-shot connection: a pooled keep-alive connection would let a + // closed listener keep answering status probes while the probes + // themselves keep the connection alive. + headers: { connection: "close" }, }); if (!response.ok) throw new Error(`Control status request returned ${response.status}`); return await response.json(); @@ -107,6 +111,7 @@ const controlTransport: ControlTransport["Service"] = { const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, { method: "POST", signal: AbortSignal.timeout(500), + headers: { connection: "close" }, }); if (!response.ok) throw new Error(`Control stop request returned ${response.status}`); }, diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts index 4f43b33fad..583db3c324 100644 --- a/packages/stack/src/platform-node.ts +++ b/packages/stack/src/platform-node.ts @@ -78,6 +78,10 @@ const controlTransport: ControlTransport["Service"] = { port: endpoint.port, path: CONTROL_STATUS_PATH, method: "GET", + // One-shot connection: a pooled keep-alive connection would + // let a closed listener keep answering status probes while + // the probes themselves keep the connection alive. + agent: false, }, (response) => { let body = ""; @@ -142,6 +146,7 @@ const controlTransport: ControlTransport["Service"] = { port: endpoint.port, path: CONTROL_STOP_PATH, method: "POST", + agent: false, }, (response) => { response.resume(); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index a87342d194..ac8e6d0d9e 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -89,6 +89,43 @@ const workspace = async (): Promise<{ throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts"); }; +/** + * Watches a directory, re-arming on ENOENT watcher errors: the runtime's + * directory watcher can report ENOENT when a watched entry (for example an + * atomic-write temp file) vanishes mid-scan. Callers keep their own timeout + * as the guard. Returns a close function. + */ +const watchDirectoryWithRetry = ( + directory: string, + onEvent: () => void, + onError: (cause: unknown) => void, +): (() => void) => { + let watcher: FSWatcher | undefined; + let closed = false; + const arm = () => { + if (closed) return; + try { + watcher = watch(directory, () => onEvent()); + watcher.once("error", (cause) => { + watcher?.close(); + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { + arm(); + onEvent(); + return; + } + onError(cause); + }); + } catch (cause) { + onError(cause); + } + }; + arm(); + return () => { + closed = true; + watcher?.close(); + }; +}; + const waitForFile = (path: string): Promise => new Promise((resolve, reject) => { if (existsSync(path)) { @@ -97,20 +134,21 @@ const waitForFile = (path: string): Promise => } let settled = false; let timeout: ReturnType | undefined; - let watcher: FSWatcher | undefined; + let stopWatching: (() => void) | undefined; const settle = (continuation: () => void) => { if (settled) return; settled = true; if (timeout !== undefined) clearTimeout(timeout); - watcher?.close(); + stopWatching?.(); continuation(); }; - watcher = watch(dirname(path), () => { - if (existsSync(path)) settle(resolve); - }); - watcher.once("error", (cause) => { - settle(() => reject(cause)); - }); + stopWatching = watchDirectoryWithRetry( + dirname(path), + () => { + if (existsSync(path)) settle(resolve); + }, + (cause) => settle(() => reject(cause instanceof Error ? cause : new Error(String(cause)))), + ); timeout = setTimeout( () => settle(() => reject(new Error(`timed out waiting for file ${path}`))), FILE_WAIT_TIMEOUT_MS, @@ -509,14 +547,14 @@ const waitForStackDocument = async ( if (existing?.lifecycle === lifecycle) return existing; return new Promise((resolve, reject) => { - let watcher: FSWatcher | undefined; + let stopWatching: (() => void) | undefined; let timeout: ReturnType | undefined; let settled = false; const settle = (continuation: () => void) => { if (settled) return; settled = true; if (timeout !== undefined) clearTimeout(timeout); - watcher?.close(); + stopWatching?.(); continuation(); }; const check = () => { @@ -525,9 +563,9 @@ const waitForStackDocument = async ( settle(() => resolve(document)); } }; - const fail = (cause: unknown) => settle(() => reject(cause)); - watcher = watch(stackDirectory, () => check()); - watcher.once("error", fail); + const fail = (cause: unknown) => + settle(() => reject(cause instanceof Error ? cause : new Error(String(cause)))); + stopWatching = watchDirectoryWithRetry(stackDirectory, check, fail); timeout = setTimeout( () => fail(new Error(`timed out waiting for stack document lifecycle ${lifecycle}`)), FILE_WAIT_TIMEOUT_MS, diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts index 69dca80120..d5a1d9cc39 100644 --- a/packages/stack/tests/helpers/managed-manager.ts +++ b/packages/stack/tests/helpers/managed-manager.ts @@ -29,7 +29,7 @@ export const setupManagedManager = (roots: Array) => { const workspace = join(root, "workspace"); mkdirSync(workspace); const stateRoot = join(root, "state"); - const layer = managedStackManagerLayer({ stateRoot }); + const layer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }); return { layer, stateRoot, workspace }; }; diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 6f28597d78..3883a4d19a 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -156,9 +156,23 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { const resolveIfReleased = () => { if (existsSync(releaseFile)) settle(Effect.void); }; - try { + // Re-arm on ENOENT watcher errors: the runtime's directory watcher can + // report ENOENT when a watched entry vanishes mid-scan. + const arm = () => { + if (settled) return; watcher = watch(dirname(releaseFile), () => resolveIfReleased()); - watcher.once("error", (cause) => settle(Effect.die(cause))); + watcher.once("error", (cause) => { + watcher?.close(); + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { + arm(); + resolveIfReleased(); + return; + } + settle(Effect.die(cause)); + }); + }; + try { + arm(); writeFileSync(readyFile, "ready"); resolveIfReleased(); } catch (cause) { @@ -205,7 +219,7 @@ const testPlatform = (): "node" | "bun" => process.env["SUPABASE_STACK_TEST_PLATFORM"] === "bun" ? "bun" : "node"; const managerLayer = (stateRoot: string, platform: "node" | "bun") => - managedStackManagerLayer({ stateRoot }).pipe( + managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( Layer.provide( platform === "bun" ? Layer.mergeAll(BunFileSystem.layer, gitConfigStoreLayer, bunControlTransportLayer) From bf20385e7ea6b8cf7e90d7cfa785f2d21c7d05ed Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:32:22 +0000 Subject: [PATCH 02/63] test: deflake more flaky suites (#6248) ## TL;DR Replaces every fixed attempt-count retry budget in tests with wall-clock deadlines, retries managed control acquisition with a fresh workspace when the identity-derived port collides with a concurrent stack's live control server and makes the attempt count as "rule" in AGENTS.md .... ## ref: - fixes the supervisor fake-owner bind flake https://github.com/supabase/cli/actions/runs/32117291754/job/95649578844 - & the managed manager-ports conflict flake https://github.com/supabase/cli/actions/runs/32117383408/job/95656435059 --------- Co-authored-by: Andrew Valleteau --- AGENTS.md | 4 +- .../functions/serve/serve.integration.test.ts | 8 +- .../dev/functions-dev-runtime.unit.test.ts | 33 +++++--- .../managed-manager-ports.integration.test.ts | 81 +++++++++---------- .../stack/src/supervisor.integration.test.ts | 75 ++++++++--------- .../stack/tests/helpers/managed-manager.ts | 28 +++++++ 6 files changed, 125 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d2eb20425..edfd1fc3be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,9 +167,9 @@ See `apps/cli/src/commands/login/` as the canonical example. ### Flake-resistant tests -Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case. +Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; bound waits by wall-clock deadline, never attempt counts; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case. -During review, arbitrary sleeps, wall-clock completion assertions, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented. +During review, arbitrary sleeps, wall-clock completion assertions, attempt-count retry budgets, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented. ### Integration test pattern diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index d0bcc47a72..ffc7631422 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -229,13 +229,13 @@ async function extractDockerEnvEntries(call: { args: ReadonlyArray; opti function waitFor(condition: () => boolean, message: string) { return Effect.gen(function* () { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (condition()) { - return; + const deadline = Date.now() + 3_000; + while (!condition()) { + if (Date.now() >= deadline) { + return yield* Effect.fail(new Error(message)); } yield* Effect.sleep(Duration.millis(20)); } - return yield* Effect.fail(new Error(message)); }); } diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts index ae53560c6b..8628ac6b66 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Duration, Effect, Fiber, Layer, Queue, Stream } from "effect"; +import { Deferred, Duration, Effect, Fiber, Layer, Queue, Stream } from "effect"; import { join } from "node:path"; import { FileWatcher, @@ -9,6 +9,15 @@ import { watchPaths } from "./functions-dev-runtime.ts"; function makeFakeFileWatcher() { const queues = new Map>>(); + const registrations = new Map>(); + const registrationFor = (path: string) => { + let latch = registrations.get(path); + if (latch === undefined) { + latch = Deferred.makeUnsafe(); + registrations.set(path, latch); + } + return latch; + }; const layer = Layer.succeed( FileWatcher, @@ -17,20 +26,18 @@ function makeFakeFileWatcher() { Stream.callback>((queue) => Effect.sync(() => { queues.set(path, queue); - }), + }).pipe(Effect.andThen(Deferred.succeed(registrationFor(path), undefined))), ), }), ); - const awaitWatch = Effect.fnUntraced(function* (expectedPath: string) { - for (let attempt = 0; attempt < 50; attempt++) { - if (queues.has(expectedPath)) { - return; - } - yield* Effect.sleep("1 millis"); - } - throw new Error(`No watcher registered for ${expectedPath}`); - }); + const awaitWatch = (expectedPath: string) => + Deferred.await(registrationFor(expectedPath)).pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => Effect.die(new Error(`No watcher registered for ${expectedPath}`)), + }), + ); const emit = (path: string, events: ReadonlyArray) => Effect.sync(() => { @@ -59,7 +66,7 @@ describe("functions dev runtime", () => { emitted = true; }), ), - Effect.timeout(Duration.seconds(1)), + Effect.timeout(Duration.seconds(5)), Effect.provide(watcher.layer), Effect.forkChild({ startImmediately: true }), ); @@ -84,7 +91,7 @@ describe("functions dev runtime", () => { ]).pipe( Stream.take(1), Stream.runCollect, - Effect.timeout(Duration.seconds(1)), + Effect.timeout(Duration.seconds(5)), Effect.provide(watcher.layer), Effect.forkChild({ startImmediately: true }), ); diff --git a/packages/stack/src/managed-manager-ports.integration.test.ts b/packages/stack/src/managed-manager-ports.integration.test.ts index cccc996003..a1676de2e3 100644 --- a/packages/stack/src/managed-manager-ports.integration.test.ts +++ b/packages/stack/src/managed-manager-ports.integration.test.ts @@ -1,7 +1,7 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Effect, Exit } from "effect"; -import { mkdirSync, mkdtempSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; @@ -9,10 +9,10 @@ import { ManagedStackManager } from "./managed/manager.ts"; import { ManagedExactPortOccupiedError } from "./managed/model.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { acquireControl, CONTROL_PORT_RANGE, controlEndpoint } from "./managed/control.ts"; -import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { reservePortSet } from "./PortAllocator.ts"; import { + acquireWorkspaceControl, automaticDocument, automaticRuntimeDocument, cleanupRoots, @@ -33,13 +33,11 @@ const setup = () => setupManagedManager(roots); describe("managed stack ports journeys", () => { it.live("retains the same automatic ports while stopped", () => { - const { layer, workspace } = setup(); + const { layer, workspace: base } = setup(); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { workspace, ownership } = yield* acquireWorkspaceControl(base); if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); const first = yield* manager.startStack({ workspacePath: workspace, @@ -70,14 +68,12 @@ describe("managed stack ports journeys", () => { }); it.live("reserves exact durable and automatic runtime ports through one lease", () => { - const { layer, workspace } = setup(); + const { layer, workspace: base } = setup(); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; const apiPort = yield* freePort(); - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { workspace, ownership } = yield* acquireWorkspaceControl(base); if (ownership._tag !== "Owned") throw new Error("expected ownership"); const started = yield* manager.startStack({ workspacePath: workspace, @@ -150,13 +146,11 @@ describe("managed stack ports journeys", () => { }); it.live("allows an unused exact API port inside the control range", () => { - const { layer, workspace } = setup(); + const { layer, workspace: base } = setup(); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { workspace, ownership } = yield* acquireWorkspaceControl(base); if (ownership._tag !== "Owned") throw new Error("expected stack ownership"); const port = ownership.endpoint.port === 15_432 ? 15_433 : 15_432; const started = yield* manager.startStack({ @@ -179,15 +173,15 @@ describe("managed stack ports journeys", () => { }); it.live("rejects an exact API port matching a persisted stack control endpoint", () => { - const { layer, workspace } = setup(); - const otherWorkspace = join(workspace, "other"); - mkdirSync(otherWorkspace); + const { layer, workspace: base } = setup(); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; - const otherEnvironment = yield* ensureEnvironment(otherWorkspace); - const otherStackId = deriveStackId(otherEnvironment.identity, "default"); - const otherOwnership = yield* acquireControl({ stackId: otherStackId }); + const { + workspace: otherWorkspace, + stackId: otherStackId, + ownership: otherOwnership, + } = yield* acquireWorkspaceControl(base, "other"); if (otherOwnership._tag !== "Owned") throw new Error("expected other stack ownership"); const other = yield* manager.startStack({ workspacePath: otherWorkspace, @@ -199,9 +193,7 @@ describe("managed stack ports journeys", () => { yield* otherOwnership.close; const otherEndpoint = yield* controlEndpoint(otherStackId); - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { workspace, ownership } = yield* acquireWorkspaceControl(base); if (ownership._tag !== "Owned") throw new Error("expected stack ownership"); const rejected = yield* manager .startStack({ @@ -230,7 +222,7 @@ describe("managed stack ports journeys", () => { }); it.live("attributes an exact API port collision with an external listener", () => { - const { layer, workspace } = setup(); + const { layer, workspace: base } = setup(); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; @@ -241,9 +233,7 @@ describe("managed stack ports journeys", () => { ); expect(external.listening).toBe(true); - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { workspace, ownership } = yield* acquireWorkspaceControl(base); if (ownership._tag !== "Owned") throw new Error("expected stack ownership"); const rejected = yield* manager .startStack({ @@ -275,10 +265,6 @@ describe("managed stack ports journeys", () => { const { layer } = setup(); const root = mkdtempSync(join(tmpdir(), "managed-auto-test-")); roots.push(root); - const firstWorkspace = join(root, "first"); - const secondWorkspace = join(root, "second"); - mkdirSync(firstWorkspace); - mkdirSync(secondWorkspace); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; @@ -286,9 +272,11 @@ describe("managed stack ports journeys", () => { if (apiPort === undefined || dbPort === undefined) { throw new Error("expected isolated automatic ports"); } - const environment = yield* ensureEnvironment(firstWorkspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { + workspace: firstWorkspace, + stackId, + ownership, + } = yield* acquireWorkspaceControl(root, "first"); if (ownership._tag !== "Owned") throw new Error("expected ownership"); const exact = yield* manager.startStack({ workspacePath: firstWorkspace, @@ -361,7 +349,14 @@ describe("managed stack ports journeys", () => { }); } yield* external.releaseAll; - const second = yield* startWithOwner(manager, secondWorkspace, automaticRuntimeDocument()); + const { workspace: secondWorkspace, ownership: secondOwnership } = + yield* acquireWorkspaceControl(root, "second"); + if (secondOwnership._tag !== "Owned") throw new Error("expected second ownership"); + const second = yield* manager.startStack({ + workspacePath: secondWorkspace, + portDocument: automaticRuntimeDocument(), + ownership: secondOwnership, + }); expect(second.stack.ports[0]?.port).not.toBe(first.stack.ports[0]?.port); yield* releaseLease(second); }), @@ -378,8 +373,6 @@ describe("managed stack ports journeys", () => { const { layer } = setup(); const root = mkdtempSync(join(tmpdir(), "managed-drift-test-")); roots.push(root); - const workspace = join(root, "workspace"); - mkdirSync(workspace); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; @@ -387,9 +380,11 @@ describe("managed stack ports journeys", () => { if (original === undefined || dbPort === undefined || changed === undefined) { throw new Error("expected drift ports"); } - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const initialOwnership = yield* acquireControl({ stackId }); + const { + workspace, + stackId, + ownership: initialOwnership, + } = yield* acquireWorkspaceControl(root); if (initialOwnership._tag !== "Owned") throw new Error("expected ownership"); const running = yield* manager.startStack({ workspacePath: workspace, @@ -436,15 +431,11 @@ describe("managed stack ports journeys", () => { const { layer } = setup(); const root = mkdtempSync(join(tmpdir(), "managed-retired-test-")); roots.push(root); - const workspace = join(root, "workspace"); - mkdirSync(workspace); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; const port = yield* freePort(); - const env = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(env.identity, "default"); - const ownership = yield* acquireControl({ stackId }); + const { workspace, ownership } = yield* acquireWorkspaceControl(root); if (ownership._tag !== "Owned") throw new Error("expected ownership"); const first = yield* manager.startStack({ workspacePath: workspace, diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index ac8e6d0d9e..e8b0e28cd7 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -380,27 +380,13 @@ const canBind = (port: number): Promise => }); }); -const listenStartingOwner = async ( +const bindFakeOwner = async ( endpoint: ControlEndpoint, - ownershipId: string, + makeServer: () => ReturnType, ): Promise> => { - for (let attempt = 0; attempt < 100; attempt += 1) { - const server = createHttpServer((request, response) => { - if (request.method === "GET" && request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - protocolVersion: 1, - ownershipId, - state: "starting", - ready: false, - }), - ); - return; - } - response.writeHead(404); - response.end(); - }); + const deadline = Date.now() + 10_000; + do { + const server = makeServer(); try { await new Promise((resolve, reject) => { server.once("error", reject); @@ -414,18 +400,41 @@ const listenStartingOwner = async ( server.close(); await new Promise((resolve) => setTimeout(resolve, 10)); } - } + } while (Date.now() < deadline); throw new Error(`timed out binding fake owner at ${endpoint.url}`); }; -const listenOwnerSequence = async ( +const listenStartingOwner = ( + endpoint: ControlEndpoint, + ownershipId: string, +): Promise> => + bindFakeOwner(endpoint, () => + createHttpServer((request, response) => { + if (request.method === "GET" && request.url === "/owner") { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + protocolVersion: 1, + ownershipId, + state: "starting", + ready: false, + }), + ); + return; + } + response.writeHead(404); + response.end(); + }), + ); + +const listenOwnerSequence = ( endpoint: ControlEndpoint, ownershipId: string, states: ReadonlyArray<"starting" | "stopping">, onRead: (state: "starting" | "stopping") => void = () => undefined, closeAfterSequence = true, -): Promise> => { - for (let attempt = 0; attempt < 100; attempt += 1) { +): Promise> => + bindFakeOwner(endpoint, () => { let reads = 0; const server = createHttpServer((request, response) => { if (request.method !== "GET" || request.url !== "/owner") { @@ -449,22 +458,8 @@ const listenOwnerSequence = async ( }, ); }); - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(endpoint.port, endpoint.hostname, () => { - server.off("error", reject); - resolve(); - }); - }); - return server; - } catch { - server.close(); - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - throw new Error(`timed out binding fake owner at ${endpoint.url}`); -}; + return server; + }); const listenStoppingOwner = async ( endpoint: ControlEndpoint, @@ -1073,7 +1068,7 @@ describe("detached supervisor child journeys", () => { } }); - test("bounds attached-owner recovery to one startup deadline", { timeout: 10_000 }, async () => { + test("bounds attached-owner recovery to one startup deadline", { timeout: 30_000 }, async () => { const roots = await workspace(); const input = messageFor(roots); const attachedReady = join(roots.root, "attached-before-ready-ready"); diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts index d5a1d9cc39..aa1b89b8c6 100644 --- a/packages/stack/tests/helpers/managed-manager.ts +++ b/packages/stack/tests/helpers/managed-manager.ts @@ -134,6 +134,34 @@ export const closeExternal = (server: Server): Promise => server.close((error) => (error === undefined ? resolve() : reject(error))); }); +/** + * Control endpoints project two identity-hash bytes into `CONTROL_PORT_RANGE`, + * so parallel test files can land on a port already owned by another live + * stack's control server. Acquires control for a fresh directory under `base`, + * retrying with a new directory (a new path-seeded identity, so a new port) on + * a conflict until a wall-clock deadline, rethrowing the last conflict. + */ +export const acquireWorkspaceControl = (base: string, prefix = "workspace") => + Effect.gen(function* () { + const deadline = Date.now() + 10_000; + for (;;) { + const workspace = mkdtempSync(join(base, `${prefix}-`)); + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const acquired = yield* acquireControl({ stackId }).pipe( + Effect.map((ownership) => ({ ownership })), + Effect.catch((error) => + error._tag === "ControlAddressConflictError" && Date.now() < deadline + ? Effect.succeed(undefined) + : Effect.fail(error), + ), + ); + if (acquired !== undefined) { + return { workspace, environment, stackId, ownership: acquired.ownership }; + } + } + }); + export const startWithOwner = ( manager: ManagedStackManagerShape, workspacePath: string, From 0c721790ff19cdf020e3896e5351fbfb16d6c63d Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:49:24 +0000 Subject: [PATCH 03/63] fix(cli): restore postgres after role reverts (CLI-2205) (#6246) ## TL;DR Passwordless `db push --linked` breaks on any migration containing `reset role`: the login role path relies on a session level `SET SESSION ROLE postgres` that the migration itself undoes. File runners now re-assert the step-down immediately after each role-reverting statement, at the end of each file, and before every CLI owned ledger write, so the whole file behaves the same on both auth paths. ## whats biting? The passwordless path connects as a temp `cli_login_*` role and steps down to `postgres` once at connect. A migration's `reset role` reverts the session to the login role, so: - the appended history insert fails with `permission denied for schema supabase_migrations (SQLSTATE 42501)` and rolls the migration back, even though every user statement succeeded - authored transaction and pg-delta no-transaction files commit their statements but never record, so the next push re-applies them - statements between the `reset role` and the end of the same file run as the login role, so `granted by current_user` cleanup silently no-ops while the push exits 0 (reproduced on staging: the stale `pg_auth_members` grant survives) - later files and the `seed_files` upsert run as the login role too ## fixed now by: - `LegacyDbSession.restoreRoleSql` (set only when the step-down ran) is injected by every file runner right after each top-level role revert (`RESET ROLE`, `SET [SESSION] ROLE [TO|=] NONE|DEFAULT` including a case-sensitively quoted `'none'`, `RESET SESSION AUTHORIZATION`, `SET SESSION AUTHORIZATION DEFAULT`, `DISCARD ALL`), and again at end of file and before the history insert and both `seed_files` upserts, so `current_user` matches a password session for the whole file - injected restores never shift `At statement: N` and are never recorded in the history row; deliberate `set role ` choreography is untouched, and password, local and plain `--db-url` sessions see a byte identical statement stream - the residual (dynamic SQL, `SET LOCAL ROLE NONE`, `session_user` itself) is documented in `docs/go-cli-divergences.md` with the end-of-file restore protecting every CLI owned write; both `SIDE_EFFECTS.md` tables record the new statements ## ref: - closes: https://github.com/supabase/cli/issues/6236 --- apps/cli/docs/go-cli-divergences.md | 25 ++ .../legacy/commands/db/push/SIDE_EFFECTS.md | 15 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 15 +- .../shared/legacy-db-connection.service.ts | 10 + .../legacy-db-connection.sql-pg.layer.ts | 1 + .../legacy/shared/legacy-migration-apply.ts | 117 ++++- .../legacy-migration-apply.unit.test.ts | 424 +++++++++++++++++- apps/cli/src/legacy/shared/legacy-seed-ops.ts | 14 +- .../shared/legacy-seed-ops.unit.test.ts | 44 +- apps/cli/src/legacy/shared/legacy-seed.ts | 14 +- .../legacy/shared/legacy-seed.unit.test.ts | 41 ++ 11 files changed, 690 insertions(+), 30 deletions(-) diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index ce946eb4d5..228fc2af93 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -118,6 +118,31 @@ These commands exist in the TS CLI today but have no direct top-level equivalent empty tests directory, or a bind the daemon resolved against a different filesystem than the CLI's (a sibling-container Docker socket) all reported a green build that ran zero tests. The TAP stream on stdout is unchanged; the diagnostic goes to stderr like every other failure. +- SQL file runners on a stepped-down session re-assert `SET SESSION ROLE postgres` + immediately after each top-level role revert (`RESET ROLE`, the generic-`SET` spellings + such as `SET ROLE [TO|=] NONE|DEFAULT` and a case-sensitively quoted `'none'`, + `RESET SESSION AUTHORIZATION`, `SET SESSION AUTHORIZATION DEFAULT`, `DISCARD ALL`), + at the end of each file, and before the migration history insert and the `seed_files` + upsert — so the whole file (including a post-reset `granted by current_user` cleanup), + every CLI-owned ledger write, and every subsequent file run as `postgres`, matching a + password session for `current_user` and privilege checks (CLI-2205, #6236). + `session_user` remains the login role and `current_setting('role')` reads `postgres` + rather than `none`, so a file keying on `session_user` still diverges. A stepped-down + session is any remote connection authenticating as `cli_login_*` (the passwordless + linked path) or `supabase_admin`, which steps down with a session-level + `SET SESSION ROLE postgres`; a file's own `RESET ROLE` reverted it to the login role, + so the appended history insert failed with SQLSTATE 42501 and any later file ran as + the login role. The old Go CLI had the same defect; on a `supabase_admin` `--db-url`, + `RESET ROLE` consequently no longer re-escalates to superuser mid-file. + Statement-level re-assertion is used because a connection-time `role=postgres` default + cannot be guaranteed through the pooler. Injected restores never shift + `At statement: N` and are never recorded in the history row. Residual: a role revert + issued through dynamic SQL, or a spelling outside the list above (for example + `SET LOCAL ROLE NONE` — deliberately unmatched, a session-scoped restore would + override its transaction scope — or the `session_authorization` GUC spellings), is + invisible to the lexical check, so statements after it run as the login role until + the next restore point; the end-of-file restore still protects every CLI-owned write. + (`RESET ALL` needs no entry — `role` carries `GUC_NO_RESET_ALL`.) - `functions serve` per-function env discovery (CLI-2184, #6179): without `--env-file`, each `supabase/functions//.env` overrides matching values from the shared `supabase/functions/.env` for that Function only; an explicit `--env-file` remains the diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index c063d2dcac..fdf1627eb5 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -27,13 +27,14 @@ before migrations unless `--skip-vault` is set. ## Database Mutations -| Statement | When | -| ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use an implicit extended-protocol batch with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes | -| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | -| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use an implicit extended-protocol batch with one final `Sync` | -| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | -| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | +| Statement | When | +| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use an implicit extended-protocol batch with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use an implicit extended-protocol batch with one final `Sync` | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | +| `SET SESSION ROLE postgres` | stepped-down sessions only (`cli_login_*`/`supabase_admin`): after each top-level role-reverting statement, at the end of each migration/globals/seed file, and before the history insert and the `seed_files` upsert (CLI-2205, #6236) | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 5a3d086bac..3596528eeb 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -78,13 +78,14 @@ child) is fully native as of CLI-1958. ### Remote path (native, in TS) -| Statement | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | -| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | -| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | -| schema-file statements (no history bookkeeping, no `RESET ALL` between files) | `--experimental` + no resolved version + pg-delta not enabled (see Notes) | -| migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | otherwise, when `[db.migrations].enabled`, for migrations `≤ --version` | -| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` (runs after either branch above) | +| Statement | When | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| schema-file statements (no history bookkeeping, no `RESET ALL` between files) | `--experimental` + no resolved version + pg-delta not enabled (see Notes) | +| migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | otherwise, when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` (runs after either branch above) | +| `SET SESSION ROLE postgres` | stepped-down sessions only: after each role-reverting statement, at end of each file, before ledger writes | ### Local path (native, in TS) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index 73d471b2a6..674876e74c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -95,6 +95,16 @@ export interface LegacyDbBatchStatement { * underlying connection when its `Scope` closes. */ export interface LegacyDbSession { + /** + * SQL that restores the role this session stepped down to after authenticating + * as a temp/privileged login role (`SET SESSION ROLE postgres`). Absent when no + * step-down ran. A migration's own `RESET ROLE` reverts the session to the + * login role — not `postgres` — so file runners re-assert this immediately after + * each top-level role-reverting statement, at the end of each file, and before + * CLI-owned ledger writes (supabase/cli#6236). Must stay a fixed, non-user-derived + * statement: consumers embed it verbatim in batches. + */ + readonly restoreRoleSql?: string; /** Run a single SQL statement, ignoring any returned rows. */ readonly exec: (sql: string) => Effect.Effect; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 8f56cb1166..d938298368 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -1056,6 +1056,7 @@ const connect = ( }; const session: LegacyDbSession = { + ...(stepDownRequired ? { restoreRoleSql: SET_SESSION_ROLE } : {}), exec: (sql) => client.unsafe(sql).pipe(Effect.asVoid, Effect.mapError(legacyToExecError)), execBatch, query: (sql, params) => diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index fbe54501df..f19e292a1c 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -124,6 +124,34 @@ export const legacyHasTransactionControl = (sql: string): boolean => { return TRANSACTION_CONTROL_PATTERN.test(upper); }; +const ROLE_REVERT_PATTERN = + /^(?:RESET\s+ROLE|RESET\s+SESSION\s+AUTHORIZATION|SET\s+(?:SESSION\s+)?ROLE(?:\s+TO\s+|\s*=\s*|\s+)(?:NONE|DEFAULT)|SET\s+SESSION\s+AUTHORIZATION\s+DEFAULT|DISCARD\s+ALL)(?:\s|;|$)/u; + +// PostgreSQL's `check_role` compares the quoted value case-sensitively against +// "none", so the quoted spellings are matched before the uppercase fold — +// `SET ROLE "NONE"` selects a real role named `NONE`, never a reset. +const QUOTED_ROLE_VALUE_PATTERN = + /^SET\s+(?:SESSION\s+)?ROLE(?:\s+TO\s+|\s*=\s*|\s+)(['"])(.*?)\1(?:\s|;|$)/iu; + +/** + * Whether a top-level statement reverts a stepped-down session to its login role + * (`RESET ROLE`, the generic-`SET` spellings of `role`'s reset, `RESET SESSION + * AUTHORIZATION` and friends, `DISCARD ALL`). File runners re-assert `postgres` + * right after each match, so the rest of the file keeps `current_user = postgres` + * as on a password session (supabase/cli#6236); reverts a lexical check cannot + * see (dynamic SQL, `SET LOCAL ROLE NONE` — deliberately unmatched, since a + * session-scoped restore would override its transaction scope) are backstopped + * by the trailing restore before any CLI-owned write. `RESET ALL` is + * deliberately absent — `role` carries `GUC_NO_RESET_ALL`. + */ +export const legacyRevertsToLoginRole = (sql: string): boolean => { + const trimmed = legacyTrimLeadingSqlComments(sql); + return ( + ROLE_REVERT_PATTERN.test(trimmed.toUpperCase()) || + QUOTED_ROLE_VALUE_PATTERN.exec(trimmed)?.[2] === "none" + ); +}; + const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length; // `startBufSize` — the fixed initial scanner @@ -506,14 +534,22 @@ const formattedExecBatchDbError = (error: unknown): LegacyDbExecError | undefine * cannot run in a transaction block: the open batch is flushed (committed), the * statement runs standalone, then batching resumes (supabase/cli#5156). The history * insert goes in the final batch, so the migration is recorded only after every - * statement succeeds. A file with no such statements uses one batch and one Sync. + * statement succeeds. On a stepped-down session ({@link LegacyDbSession.restoreRoleSql}) + * the `postgres` role is re-asserted immediately after each top-level role-reverting + * statement ({@link legacyRevertsToLoginRole}) and again at the end of the file before + * the history insert (supabase/cli#6236), so the whole file behaves as on a password + * session and leaves the session role-clean for whatever runs next. Injected restores + * never shift `At statement: N` and are never recorded in the history row. + * A file with no such statements uses one batch and one Sync. * Pg-delta files whose first line is `-- pg-delta: transaction=false` instead run * every statement sequentially without a CLI-owned transaction. This keeps their * session preamble, nontransactional action, and cleanup on the same connection. * * Does NOT create the history table and does not unconditionally `RESET ALL` — * those are the migration-apply path's responsibility, so ordinary role/globals - * files (`legacySeedGlobals`) stay reset-free. The one exception is best-effort + * files (`legacySeedGlobals`) stay reset-free. (The role re-assert above is not + * session hygiene but a connection-layer invariant, so it applies to every file + * runner, globals included.) The one exception is best-effort * cleanup after a failed pg-delta no-transaction file. When `forceNoVersion` is set * the history insert is skipped regardless of filename. * @@ -594,6 +630,8 @@ const execMigrationBatch = ( const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; + const restoreRole = session.restoreRoleSql; + const executeSequentially = (cleanup: string) => Effect.gen(function* () { for (const [index, statement] of statements.entries()) { @@ -602,6 +640,25 @@ const execMigrationBatch = ( .pipe( Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement)), ); + if (restoreRole !== undefined && legacyRevertsToLoginRole(statement)) { + yield* session + .exec(restoreRole) + .pipe( + Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, restoreRole)), + ); + } + } + if ( + restoreRole !== undefined && + !(statements.length > 0 && legacyRevertsToLoginRole(statements[statements.length - 1]!)) + ) { + yield* session + .exec(restoreRole) + .pipe( + Effect.mapError((cause) => + legacyFormatExecBatchError(cause, statements.length, restoreRole), + ), + ); } if (version.length > 0) { yield* session @@ -612,7 +669,18 @@ const execMigrationBatch = ( ), ); } - }).pipe(Effect.tapError(() => session.exec(cleanup).pipe(Effect.ignore))); + }).pipe( + Effect.tapError(() => + Effect.gen(function* () { + yield* session.exec(cleanup).pipe(Effect.ignore); + // Sequential statements ran outside a CLI transaction, so a failed + // file's `RESET ROLE` survives the cleanup; restore best-effort. + if (restoreRole !== undefined) { + yield* session.exec(restoreRole).pipe(Effect.ignore); + } + }), + ), + ); // The pg-delta directive is file-level execution metadata. Run the complete // sequence on this session without adding transaction boundaries so session @@ -636,16 +704,42 @@ const execMigrationBatch = ( let pending: Array = []; let executed = 0; - const flushBatch = (recordVersion: boolean) => + const flushBatch = (final: boolean) => Effect.gen(function* () { - if (pending.length === 0 && !recordVersion) return; + const recordVersion = final && version.length > 0; + const trailingRestore = final ? restoreRole : undefined; + if (pending.length === 0 && !recordVersion && trailingRestore === undefined) return; const batchStatements = pending; - const operations: Array = batchStatements.map((sql) => ({ sql })); + const operations: Array = []; + // Injected role restores don't count toward `At statement: N`; track how + // many precede each op so failures keep the file's own numbering (a + // mid-file restore inherits its host statement's index; the trailing + // restore and the history insert report the file's statement count). + const injectedBefore: Array = []; + let injected = 0; + let lastOpIsInjectedRestore = false; + for (const sql of batchStatements) { + operations.push({ sql }); + injectedBefore.push(injected); + lastOpIsInjectedRestore = false; + if (restoreRole !== undefined && legacyRevertsToLoginRole(sql)) { + injected += 1; + operations.push({ sql: restoreRole }); + injectedBefore.push(injected); + lastOpIsInjectedRestore = true; + } + } + if (trailingRestore !== undefined && !lastOpIsInjectedRestore) { + operations.push({ sql: trailingRestore }); + injectedBefore.push(injected); + injected += 1; + } if (recordVersion) { operations.push({ sql: INSERT_MIGRATION_VERSION, params: [version, name, statements], }); + injectedBefore.push(injected); } const base = executed; yield* session.execBatch(operations).pipe( @@ -656,11 +750,12 @@ const execMigrationBatch = ( if (cause instanceof LegacyDbConnectError) return cause; // `statementIndex` is set by every batch failure the driver raises; a // session that omits it can only have failed before the first statement. - const globalIndex = base + (cause.statementIndex ?? 0); + const raw = cause.statementIndex ?? 0; + const globalIndex = base + raw - (injectedBefore[raw] ?? injected); return legacyFormatExecBatchError( cause, globalIndex, - statements[globalIndex] ?? INSERT_MIGRATION_VERSION, + operations[raw]?.sql ?? statements[globalIndex] ?? INSERT_MIGRATION_VERSION, ); }), ); @@ -682,7 +777,7 @@ const execMigrationBatch = ( pending.push(statement); } } - yield* flushBatch(version.length > 0); + yield* flushBatch(true); }).pipe( Effect.mapError((error) => // A batch connection failure is not an execution failure: it keeps its own @@ -834,7 +929,9 @@ export const legacyExecSqlFile = ( * relative, verbatim when absolute) via the shared glob * ({@link legacySqlFilesGlob}), then runs each matched file's statements with * {@link legacyExecSqlFile} in glob order — no history table, no history row, and no - * `RESET ALL` between files: connection state is never reset here. + * `RESET ALL` between files: connection state is never reset here (a stepped-down + * session's role re-assert at each file's end is the one exception — see + * {@link LegacyDbSession.restoreRoleSql}). * * Callers gate the call on the three-conjunct condition (`--experimental` + no resolved * version + pg-delta NOT enabled) themselves — this function only performs diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index aceab90bed..9bd46cf31d 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -19,6 +19,7 @@ import { legacyHasTransactionControl, legacyIsPipelineIncompatible, legacyMarkError, + legacyRevertsToLoginRole, legacySeedGlobals, } from "./legacy-migration-apply.ts"; @@ -41,6 +42,7 @@ function fakeSession( failOn?: string; failAfterBatch?: boolean; failWith?: { message: string; code?: string; detail?: string; position?: number }; + restoreRoleSql?: string; } = {}, ) { const calls: Array<{ @@ -50,6 +52,7 @@ function fakeSession( params?: ReadonlyArray; }> = []; const session: LegacyDbSession = { + ...(opts.restoreRoleSql === undefined ? {} : { restoreRoleSql: opts.restoreRoleSql }), exec: (sql) => { calls.push({ kind: "exec", sql }); return opts.failOn !== undefined && sql.includes(opts.failOn) @@ -78,7 +81,9 @@ function fakeSession( }, query: (sql, params) => { calls.push({ kind: "query", sql, params }); - return Effect.succeed([]); + return opts.failOn !== undefined && sql.includes(opts.failOn) + ? Effect.fail(new FakeExecError(opts.failWith ?? { message: "exec failed" })) + : Effect.succeed([]); }, extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), @@ -501,6 +506,367 @@ describe("legacyApplyMigrationFile", () => { ), ); }); + + // Passwordless remote sessions step down from the temp login role via + // `SET SESSION ROLE postgres`; a migration's own `RESET ROLE` reverts to the + // login role, which used to fail the history insert with 42501 (supabase/cli#6236). + it.effect( + "re-asserts the stepped-down role between the statements and the history insert", + () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_reset_role.sql"); + writeFileSync(file, "set role repro_writer;\ncreate table t (id int);\nreset role;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + // The restore is injected right after the trailing RESET ROLE (the + // end-of-file restore dedupes away), so the insert runs as postgres — + // and the committed session ends role-clean for the next file. + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ + "set role repro_writer", + "create table t (id int)", + "reset role", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + // The ledger row records only the file's own statements. + expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ + "set role repro_writer", + "create table t (id int)", + "reset role", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("never re-asserts a role on sessions that did not step down", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_reset_role.sql"); + writeFileSync(file, "reset role;"); + const { session, calls } = fakeSession(); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(executedSql(calls).some((sql) => sql.includes("SET SESSION ROLE"))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("re-asserts the stepped-down role before recording an authored transaction", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_authored.sql"); + writeFileSync(file, "BEGIN;\nreset role;\nCOMMIT;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const lastRestore = calls.findLastIndex( + (call) => call.kind === "exec" && call.sql === "SET SESSION ROLE postgres", + ); + const authoredCommit = calls.findLastIndex( + (call) => call.kind === "exec" && call.sql === "COMMIT", + ); + const history = calls.findIndex((call) => call.kind === "query"); + expect(lastRestore).toBeGreaterThan(authoredCommit); + expect(history).toBeGreaterThan(lastRestore); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps the history insert's statement index when the role restore precedes it", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, "SELECT 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "INSERT INTO supabase_migrations", + failWith: { + message: "ERROR: permission denied for schema supabase_migrations (SQLSTATE 42501)", + code: "42501", + }, + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("permission denied for schema supabase_migrations"); + // The CLI-internal restore op must not shift Go's `At statement: N`. + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps a mid-batch failure's statement index when a restore op is appended", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, "SELECT 1;\nSELECT bad_col;\nSELECT 3;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "bad_col", + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SELECT bad_col"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps the deferred-failure index when a restore op is appended", () => { + // Mirrors "defaults a deferred batch failure to the migration history + // statement": the restore op between the statements and the insert must not + // shift the deferred (post-Sync) index either. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_deferred.sql"); + writeFileSync(file, "SELECT 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failAfterBatch: true, + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("reports the restore op's own failure with the history step's index", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, "SELECT 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "SET SESSION ROLE", + failWith: { + message: 'ERROR: permission denied to set role "postgres" (SQLSTATE 42501)', + code: "42501", + }, + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SET SESSION ROLE postgres"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps the insert index when the final batch holds only the trailing ops", () => { + // A trailing CONCURRENTLY statement empties `pending`, so the final batch is + // just [restore, insert] — the index math must still report the file's count. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, "SELECT 1;\nCREATE INDEX CONCURRENTLY i ON a(id);"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "INSERT INTO supabase_migrations", + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("restores postgres immediately after a mid-file RESET ROLE, silently", () => { + // Statements after the reset now run as postgres again (avallete's #6246 + // review), so the old drift WARN is gone — there is no drift left to surface. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_reset_role.sql"); + writeFileSync(file, "set role r;\nreset role;\nselect 1;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + const out = mockOutput({ format: "text" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ + "set role r", + "reset role", + "SET SESSION ROLE postgres", + "select 1", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ + "set role r", + "reset role", + "select 1", + ]); + expect(out.stderrText).not.toContain("WARN:"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(out.layer), + ); + }); + + it.effect("restores postgres after every static role-revert spelling", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_role_none.sql"); + writeFileSync( + file, + "set role a;\nset role none;\nset role to none;\nreset session authorization;\nset role = default;", + ); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ + "set role a", + "set role none", + "SET SESSION ROLE postgres", + "set role to none", + "SET SESSION ROLE postgres", + "reset session authorization", + "SET SESSION ROLE postgres", + "set role = default", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("emits exactly one restore when a no-transaction file ends in a revert", () => { + // Sequential path: the injected restore after the trailing `reset role` + // makes the end-of-file restore redundant, so it must dedupe away. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_seq_reset.sql"); + writeFileSync(file, "-- pg-delta: transaction=false\nset role r;\nreset role;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.filter((sql) => sql === "SET SESSION ROLE postgres")).toHaveLength(1); + expect(execs[execs.indexOf("reset role") + 1]).toBe("SET SESSION ROLE postgres"); + expect(calls.filter((call) => call.kind === "query")).toHaveLength(1); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps the deferred-failure index when mid-file restores were injected", () => { + // The `injectedBefore[raw] ?? injected` fallback only matters when the + // deferred (post-Sync) index lands past the ops array AND injections exist. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_deferred.sql"); + writeFileSync(file, "set role r;\nreset role;\nselect 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failAfterBatch: true, + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 4"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("injects into intermediate flushes so standalone statements run as postgres", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_concurrent.sql"); + writeFileSync(file, "reset role;\nCREATE INDEX CONCURRENTLY i ON a(id);\nselect 2;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ + "reset role", + "SET SESSION ROLE postgres", + ]); + expect(batches[1]?.statements?.map(({ sql }) => sql)).toEqual([ + "select 2", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("reports a mid-file restore's own failure at its host statement", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, "set role r;\nreset role;\nselect 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "SET SESSION ROLE", + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + // The injected op inherits `reset role`'s index and shows the SQL that + // actually failed, so debugging lands on the right line of the file. + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SET SESSION ROLE postgres"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps statement numbering across an injected mid-file restore", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, "set role r;\nreset role;\nselect bad;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "select bad", + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("select bad"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); describe("legacyHasTransactionControl", () => { @@ -774,6 +1140,29 @@ describe("legacySeedGlobals", () => { Effect.provide(BunServices.layer), ); }); + + it.effect("leaves a stepped-down session role-clean after a globals file", () => { + // Globals run before the vault upsert and the history-table DDL on the same + // session, so a `reset role` here must not leak the login role into them. + const dir = mkdtempSync(join(tmpdir(), "legacy-globals-")); + const file = join(dir, "roles.sql"); + writeFileSync(file, "CREATE ROLE my_role;\nset role my_role;\nreset role;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message })); + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.at(-1)?.sql).toBe("SET SESSION ROLE postgres"); + expect( + executedSql(calls).some((sql) => sql.includes("supabase_migrations.schema_migrations")), + ).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }); }); describe("legacyApplySchemaFiles", () => { @@ -1345,3 +1734,36 @@ describe("legacyApplySchemaFiles", () => { }, ); }); + +describe("legacyRevertsToLoginRole", () => { + const cases: ReadonlyArray = [ + ["reset role", true], + ["RESET SESSION AUTHORIZATION", true], + ["set role none", true], + ["set role to none", true], + ["set role = default", true], + ["set session role none", true], + ["set session authorization default", true], + ["discard all", true], + // `check_role` compares quoted values case-sensitively against "none". + ["set role 'none'", true], + ['set role "none"', true], + ["-- c\nreset role", true], + ["set role 'NONE'", false], + ['set role "NONE"', false], + ["set role none_user", false], + ["set role nonesuch", false], + // Session-scoped restore would override the transaction scope. + ["set local role none", false], + // `role` carries GUC_NO_RESET_ALL. + ["reset all", false], + ["discard temp", false], + ["set session authorization 'bob'", false], + ["set roles none", false], + ["select 'reset role'", false], + ]; + + it.each(cases)("%s -> %s", (sql, want) => { + expect(legacyRevertsToLoginRole(sql)).toBe(want); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.ts index 2690f5fcf9..e7f6706f5f 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.ts @@ -4,7 +4,7 @@ import { Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -import { checkScannerBufferSize } from "./legacy-migration-apply.ts"; +import { checkScannerBufferSize, legacyRevertsToLoginRole } from "./legacy-migration-apply.ts"; import { legacyCreateSeedTable } from "./legacy-migration-history.ts"; import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; import { legacySplitAndTrim } from "./legacy-sql-split.ts"; @@ -140,7 +140,17 @@ export const legacySeedData = ( const statements = seed.dirty ? [] : lines; yield* session.exec("BEGIN"); const body = Effect.gen(function* () { - for (const statement of statements) yield* session.exec(statement); + for (const statement of statements) { + yield* session.exec(statement); + // A top-level role revert drops a stepped-down session to the login + // role; restore `postgres` right away (supabase/cli#6236). + if (session.restoreRoleSql !== undefined && legacyRevertsToLoginRole(statement)) { + yield* session.exec(session.restoreRoleSql); + } + } + // Backstop for reverts the lexical check cannot see, so the + // CLI-owned upsert always runs as `postgres`. + if (session.restoreRoleSql !== undefined) yield* session.exec(session.restoreRoleSql); yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); yield* session.exec("COMMIT"); }); diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts index f5288ffebb..32f0f3aa0e 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts @@ -11,9 +11,10 @@ import { legacyGetPendingSeeds, legacySeedData } from "./legacy-seed-ops.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} -function fakeSeedSession() { +function fakeSeedSession(opts: { restoreRoleSql?: string } = {}) { const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; const session: LegacyDbSession = { + ...(opts.restoreRoleSql === undefined ? {} : { restoreRoleSql: opts.restoreRoleSql }), exec: (sql) => { calls.push({ kind: "exec", sql }); return Effect.void; @@ -173,4 +174,45 @@ describe("legacySeedData (dirty parse)", () => { ), ); }); + + it.effect("re-asserts the stepped-down role before the seed_files upsert", () => { + // A seed's own `reset role` reverts a stepped-down session to the login role, + // which used to fail the CLI's hash upsert with 42501 (supabase/cli#6236). + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "data.sql"), "set role r;\ninsert into t values (1);\nreset role;"); + const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe( + Effect.tap(() => + Effect.sync(() => { + const sqls = calls.map((c) => c.sql); + const restoreAt = sqls.indexOf("SET SESSION ROLE postgres"); + const upsertAt = calls.findIndex( + (c) => c.kind === "query" && c.sql.includes("seed_files"), + ); + expect(restoreAt).toBeGreaterThan(sqls.indexOf("reset role")); + expect(upsertAt).toBeGreaterThan(restoreAt); + expect(sqls.lastIndexOf("COMMIT")).toBeGreaterThan(upsertAt); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("restores the role right after a mid-seed reset, before later statements", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "data.sql"), "set role r;\nreset role;\ninsert into t values (1);"); + const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe( + Effect.tap(() => + Effect.sync(() => { + const sqls = calls.map((c) => c.sql); + const resetAt = sqls.indexOf("reset role"); + // Injected immediately, so the following insert runs as postgres again. + expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); + expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index c3ff063822..9fbc4d7cfb 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -9,7 +9,7 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyResolveUnderWorkdir } from "./legacy-glob.ts"; -import { checkScannerBufferSize } from "./legacy-migration-apply.ts"; +import { checkScannerBufferSize, legacyRevertsToLoginRole } from "./legacy-migration-apply.ts"; import { legacyCreateSeedTable, legacyReadSeedTable, @@ -163,8 +163,18 @@ export const legacyApplySeedFiles = ( const txn = Effect.gen(function* () { yield* session.exec("BEGIN"); if (!seed.dirty) { - for (const statement of statements) yield* session.exec(statement); + for (const statement of statements) { + yield* session.exec(statement); + // A top-level role revert drops a stepped-down session to the login + // role; restore `postgres` right away (supabase/cli#6236). + if (session.restoreRoleSql !== undefined && legacyRevertsToLoginRole(statement)) { + yield* session.exec(session.restoreRoleSql); + } + } } + // Backstop for reverts the lexical check cannot see, so the + // CLI-owned upsert always runs as `postgres`. + if (session.restoreRoleSql !== undefined) yield* session.exec(session.restoreRoleSql); yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); yield* session.exec("COMMIT"); }); diff --git a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts index eb04c5bc8e..303c714338 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts @@ -144,3 +144,44 @@ describe("legacyApplySeedFiles scanner buffer size", () => { }, ); }); + +describe("legacyApplySeedFiles stepped-down session", () => { + it.effect("restores the role right after a reset and before the seed_files upsert", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "seed.sql"), "set role r;\nreset role;\ninsert into t values (1);"); + const calls: Array = []; + const session: LegacyDbSession = { + restoreRoleSql: "SET SESSION ROLE postgres", + exec: (sql) => + Effect.sync(() => { + calls.push(sql); + }), + execBatch: () => Effect.void, + query: (sql) => + Effect.sync(() => { + calls.push(sql); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + const out = mockOutput(); + return run(session, dir, ["seed.sql"], out).pipe( + Effect.tap(() => + Effect.sync(() => { + const resetAt = calls.indexOf("reset role"); + const upsertAt = calls.findIndex((sql) => + sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + // Injected immediately after the reset, so the following insert (and + // everything else in the file) runs as postgres again. + expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); + expect(upsertAt).toBeGreaterThan(resetAt); + expect(out.stderrText).not.toContain("WARN:"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); From 23e2817e8e06ad294362cbc8812e9eb92008c048 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:28:43 +0000 Subject: [PATCH 04/63] chore: sync API types from infrastructure (#6251) This PR was automatically created to sync API types from the infrastructure repository. Changes were detected in the generated API code after syncing with the latest spec from infrastructure. Co-authored-by: supabase-cli-releaser[bot] <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> --- apps/cli-go/pkg/api/types.gen.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 9bdcb1f334..2a4c3b7b1f 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -4263,6 +4263,7 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesOrioledb V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.orioledb" V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesReadReplicas V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.read_replicas" V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubConnections V1ListEntitlementsResponseEntitlementsFeatureKey = "integrations.github_connections" + V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubPushWebhooksLimit V1ListEntitlementsResponseEntitlementsFeatureKey = "integrations.github_push_webhooks_limit" V1ListEntitlementsResponseEntitlementsFeatureKeyIpv4 V1ListEntitlementsResponseEntitlementsFeatureKey = "ipv4" V1ListEntitlementsResponseEntitlementsFeatureKeyLogDrains V1ListEntitlementsResponseEntitlementsFeatureKey = "log_drains" V1ListEntitlementsResponseEntitlementsFeatureKeyLogRetentionDays V1ListEntitlementsResponseEntitlementsFeatureKey = "log.retention_days" @@ -4363,6 +4364,8 @@ func (e V1ListEntitlementsResponseEntitlementsFeatureKey) Valid() bool { return true case V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubConnections: return true + case V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubPushWebhooksLimit: + return true case V1ListEntitlementsResponseEntitlementsFeatureKeyIpv4: return true case V1ListEntitlementsResponseEntitlementsFeatureKeyLogDrains: From 8e85e79117960070ba35f62fe8bbfd668af53a8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:11:42 +0000 Subject: [PATCH 05/63] fix(deps): bump the go-minor group across 1 directory with 2 updates (#6256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go-minor group with 2 updates in the /apps/cli-go/pkg directory: [github.com/stretchr/testify](https://github.com/stretchr/testify) and [golang.org/x/mod](https://github.com/golang/mod). Updates `github.com/stretchr/testify` from 1.11.1 to 1.12.0
Release notes

Sourced from github.com/stretchr/testify's releases.

v1.12.0

What's Changed

Functional Changes

Fixes

Documentation, Build & CI

New Contributors

... (truncated)

Commits
  • 001eb79 Merge pull request #1905 from Kentzo/patch-1
  • ad40f38 Merge pull request #1906 from stretchr/dependabot/github_actions/actions/chec...
  • 3bae017 build(deps): bump actions/checkout from 6.0.2 to 6.0.3
  • f8c01f3 mock: Mock.Return does not exist anymore
  • 12f8b56 Merge pull request #1563 from stretchr/make-AssertionFunc-types-aliases
  • a11649e assert: make *AssertionFunc type just aliases
  • dc20f41 Merge pull request #1890 from stretchr/dolmen/codegen-modernize
  • 098f8d7 _codegen: use strings.Builder
  • d2699be _codegen: modernize
  • a463c8c Merge pull request #1885 from stretchr/dolmen/ci-check-ghactions-hashes
  • Additional commits viewable in compare view

Updates `golang.org/x/mod` from 0.38.0 to 0.39.0
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/go.mod | 8 +++----- apps/cli-go/pkg/go.sum | 16 ++++++---------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 9db1b48e3c..3cf35f5bc0 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -23,15 +23,14 @@ require ( github.com/oapi-codegen/runtime v1.6.0 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.0 github.com/tidwall/jsonc v0.3.3 - golang.org/x/mod v0.38.0 + golang.org/x/mod v0.39.0 google.golang.org/grpc v1.83.0 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/ethereum/go-ethereum v1.17.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect @@ -43,12 +42,11 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/lib/pq v1.10.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index 8caf9facb1..21c9a48b9c 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -17,8 +17,6 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7 github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -140,8 +138,6 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -172,8 +168,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -182,8 +178,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w= @@ -224,8 +220,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= From 31d01099d7c02bea3d5f9949e82931b515dd4b4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:09:19 +0000 Subject: [PATCH 06/63] fix(deps): bump the npm-major group with 5 updates (#6259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 5 updates: | Package | From | To | | --- | --- | --- | | [smol-toml](https://github.com/squirrelchat/smol-toml) | `1.7.1` | `1.8.0` | | [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js) | `2.112.2` | `2.112.3` | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.227` | `0.3.228` | | [@vercel/detect-agent](https://github.com/vercel/vercel/tree/HEAD/packages/detect-agent) | `1.2.4` | `1.2.5` | | [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.32.1` | `6.32.2` | Updates `smol-toml` from 1.7.1 to 1.8.0
Release notes

Sourced from smol-toml's releases.

v1.8.0

What's Changed

Full Changelog: https://github.com/squirrelchat/smol-toml/compare/v1.7.2...v1.8.0

v1.7.2

What's Changed

Full Changelog: https://github.com/squirrelchat/smol-toml/compare/v1.7.1...v1.7.2

Commits
  • 6d0f477 chore: bump version
  • 97e9713 docs: mention temporal api
  • 7a3068d perf: ubench opt for instanceof Date
  • 7e8c09a feat: stringify temporal
  • 19239d0 chore: bump version
  • 5f55c3a chore: revert sourcemap publishing
  • 5c26f1b chore: update benchmarks
  • 92832d2 refactor: better logic orchestration in structs
  • 2fb9ab8 refactor: ctx object instead of pointer arg + tuple returns
  • e6017c4 ci: use staged publish
  • See full diff in compare view

Updates `@supabase/supabase-js` from 2.112.2 to 2.112.3
Release notes

Sourced from @​supabase/supabase-js's releases.

v2.112.3

2.112.3 (2026-08-11)

🩹 Fixes

  • supabase: add trace context headers to canonical CORS allow-list (#2603)
  • supabase: improve trace propagation sampling and diagnostics (#2604)

❤️ Thank You

v2.112.3-canary.0

2.112.3-canary.0 (2026-08-11)

🩹 Fixes

  • supabase: add trace context headers to canonical CORS allow-list (#2603)
  • supabase: improve trace propagation sampling and diagnostics (#2604)

❤️ Thank You

Changelog

Sourced from @​supabase/supabase-js's changelog.

2.112.3 (2026-08-11)

🩹 Fixes

  • supabase: improve trace propagation sampling and diagnostics (#2604)
  • supabase: add trace context headers to canonical CORS allow-list (#2603)

❤️ Thank You

Commits
  • e44447c fix(supabase): improve trace propagation sampling and diagnostics (#2604)
  • 9f0358c fix(supabase): add trace context headers to canonical CORS allow-list (#2603)
  • 84beab1 chore(release): version 2.112.2 changelogs (#2599)
  • See full diff in compare view

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.227 to 0.3.228
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.228

What's changed

  • Agent tool results (AgentOutput): usage.output_tokens_details is now carried through

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.228
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.228
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.228
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.228
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.228

  • Agent tool results (AgentOutput): usage.output_tokens_details is now carried through
Commits

Updates `@vercel/detect-agent` from 1.2.4 to 1.2.5
Release notes

Sourced from @​vercel/detect-agent's releases.

@​vercel/h3@​0.1.116

Patch Changes

  • @​vercel/node@​5.9.3

@​vercel/h3@​0.1.115

Patch Changes

  • @​vercel/node@​5.9.2

@​vercel/h3@​0.1.114

Patch Changes

  • @​vercel/node@​5.9.1

@​vercel/h3@​0.1.113

Patch Changes

  • Updated dependencies [4502520]
    • @​vercel/node@​5.9.0

@​vercel/h3@​0.1.112

Patch Changes

  • @​vercel/node@​5.8.27
Commits

Updates `knip` from 6.32.1 to 6.32.2
Release notes

Sourced from knip's releases.

Release 6.32.2

  • Support oxfmt.config.mts (#1933) (795900191dc75eec8d1e717b866bf57e1e2912cc) - thanks @​joealden!
  • Support oxlint.config.mts (#1934) (531e2dc7c1d8bf31babea0068c34391182ec2d50) - thanks @​joealden!
  • Fix Supported lint-staged Configs (#1935) (f9c755e414ed10baa4d01af8ddac6d04cb8d5617) - thanks @​joealden!
  • Update dependencies (95f7c529f918dd9e1a84f92c68d064738977b825)
  • Update sentry snapshot (ea7929fcbd6b323c8bdd9252ac57017feeb29ecf)
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 6 +- packages/config/package.json | 2 +- packages/stack/package.json | 2 +- pnpm-lock.yaml | 363 +++++++++++++++++------------------ pnpm-workspace.yaml | 2 +- 5 files changed, 181 insertions(+), 194 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index af7a2f96f6..d5cc742b7e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,7 +43,7 @@ "jose": "^6.2.8" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.227", + "@anthropic-ai/claude-agent-sdk": "^0.3.228", "@anthropic-ai/sdk": "^0.116.0", "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", @@ -65,7 +65,7 @@ "@types/pg-copy-streams": "^1.2.5", "@types/react": "^19.2.18", "@typescript/native-preview": "catalog:", - "@vercel/detect-agent": "^1.2.4", + "@vercel/detect-agent": "^1.2.5", "@vitest/coverage-istanbul": "catalog:", "dotenv": "^17.4.2", "effect": "catalog:", @@ -82,7 +82,7 @@ "react": "^19.2.8", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", - "smol-toml": "^1.7.1", + "smol-toml": "^1.8.0", "tldts": "catalog:", "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "catalog:", diff --git a/packages/config/package.json b/packages/config/package.json index 99be23bdbc..20142f7605 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -21,7 +21,7 @@ "@effect/platform-node": "catalog:", "dedent": "^1.7.2", "effect": "catalog:", - "smol-toml": "^1.7.1" + "smol-toml": "^1.8.0" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/stack/package.json b/packages/stack/package.json index cb9f313ff5..cf347b8d27 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@supabase/supabase-js": "^2.112.2", + "@supabase/supabase-js": "^2.112.3", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e5aeee6c2..d2627512d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,8 @@ catalogs: specifier: 4.0.0-beta.107 version: 4.0.0-beta.107 knip: - specifier: ^6.32.1 - version: 6.32.1 + specifier: ^6.32.2 + version: 6.32.2 nx: specifier: ^23.1.1 version: 23.1.1 @@ -119,8 +119,8 @@ importers: version: 6.2.8 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.227 - version: 0.3.227(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.228 + version: 0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': specifier: ^0.116.0 version: 0.116.0(zod@4.4.3) @@ -185,8 +185,8 @@ importers: specifier: 'catalog:' version: 7.0.0-dev.20260707.2 '@vercel/detect-agent': - specifier: ^1.2.4 - version: 1.2.4 + specifier: ^1.2.5 + version: 1.2.5 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -207,7 +207,7 @@ importers: version: 5.0.0(ink@7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8))(react@19.2.8) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -236,8 +236,8 @@ importers: specifier: ^25.0.9 version: 25.0.9(@typescript/typescript6@6.0.2) smol-toml: - specifier: ^1.7.1 - version: 1.7.1 + specifier: ^1.8.0 + version: 1.8.0 tldts: specifier: 'catalog:' version: 7.4.10 @@ -296,7 +296,7 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -376,7 +376,7 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -418,7 +418,7 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -451,8 +451,8 @@ importers: specifier: 'catalog:' version: 4.0.0-beta.107 smol-toml: - specifier: ^1.7.1 - version: 1.7.1 + specifier: ^1.8.0 + version: 1.8.0 devDependencies: '@tsconfig/bun': specifier: 'catalog:' @@ -468,7 +468,7 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -508,7 +508,7 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -541,8 +541,8 @@ importers: specifier: 'catalog:' version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10) '@supabase/supabase-js': - specifier: ^2.112.2 - version: 2.112.2 + specifier: ^2.112.3 + version: 2.112.3 '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 @@ -557,7 +557,7 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.32.1 + version: 6.32.2 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -601,52 +601,52 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.227': - resolution: {integrity: sha512-9iL2Q5QSLAVRgAZnXeSag6g2k9e7ZOHktYxL5LzTg05lbcYCeop4eVs7R8+qsJRIHtbGthA919hrnzkq1Ij9GQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228': + resolution: {integrity: sha512-HuCsV3/5XuYYaWuCbksX+e0JkDDUG/AlFJ8wKhDL3PBW/3hHNd6xBYx88kEWk1Z6B1GLxwHht9624lcmscpsyw==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.227': - resolution: {integrity: sha512-GDdYKtv3wC0kLGS5wrxUf5Oq2hkKW0Nw/CyqcxHEnmeuc8istbQM6dveTK66ufPyI5Q7FiHxmo/kL7YLlFdvtw==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228': + resolution: {integrity: sha512-jSUYY5Nd3efvbLZPU+i0tRBaFXskHu8M+4LMGBEw6A0PaklZ3YfGvKlTOWtJGRw6vMc6LzfOFts024xPNm6OrQ==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.227': - resolution: {integrity: sha512-21cruWs2wTVUYqpDn2z3SgG3fnDmf3tpH7iSw/V1NIrvaiiN+tFfeliIEvZPzLzT26048GEvK6GZcFG5k5mshQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228': + resolution: {integrity: sha512-4PgfisC3kHKlzJvy3rrm4Oh26g+D78h4ahHjni9fvSKHuJgrvHu9Qgo6aaYmzWdc7v9drL+pgiCk5Ge4Y2ANPA==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.227': - resolution: {integrity: sha512-/Ve9ZVcULeYP6kxaEBFEwCDPAJRl/9O38PgXcOSCYIott6H3IA+4nb51Ee0ljITeA52IPyEo7stbduIjiPsp+g==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228': + resolution: {integrity: sha512-0Wjv6TiWwGlBZINAmNJX07jN359jKwB/4Sr/uWgQkdjuVIOhe/M8ydk7JL2EPqCsbiW1lc15NjE5MWpZiYqooA==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.227': - resolution: {integrity: sha512-YfpJj8YVkzG2MhPOnc24NtErwWRpWYDNxc2Ig359kR4ZbfGKlmonKvkXceUQaeFXSh1LNUZAjsm2Y04XdBaUbQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228': + resolution: {integrity: sha512-dnXxyiwGCZj27HVk6clYRqGMgrs3KVLVp0vvWYLjkPGBiKbI83qJiDpOfaekEXG2I4elX0M4XikggV1LGWjimg==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.227': - resolution: {integrity: sha512-eBSNIOauM5+crbIH/f8G1vgGMIMHVwh+NDH6esCRKdbRVvcnE4+urdVFSaKz0G7Ji8c6WGKLQYqrdrAVdPyODw==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228': + resolution: {integrity: sha512-LmGplObceqMOu5mlrlhTZL/VSrEWdZagF0Bl8awglMu6WeQcNe7StORYkCznZ0BuzV4CwuC3ipV4q8Jrs66wSg==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.227': - resolution: {integrity: sha512-4vkR0Hn1TI6ypomZcpMTk7h8Wtk1iY59XDtpyk5bk7eMNxuNtlm8a4DX7OS00sn4/I3cUoDOhRpTVA4p7j5oxA==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228': + resolution: {integrity: sha512-mNS5yIMz/OXSQiDErb84jA8AKBFSlS9RSZ0qn2qyGkxplUx7kVmIDg/KnwOwHmygpzmH4UmR6OCaLXGohupqNA==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.227': - resolution: {integrity: sha512-iCEk00U8o8Y+0fsSCFbFGWGRokrfxo9kCwzBuKPjzvQEux06g7ps/O+Y0q9Nw/4iPCTXgUbekiSNcqg9CWaN9A==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228': + resolution: {integrity: sha512-DYT3HvdS64Pq0IRvgW3RDO31yjYp5yiUKoKaZolTpLKfALpG5LI/osfnKlya68PZ/bSST1FNAfW9I0EtCnaQ4w==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.227': - resolution: {integrity: sha512-jNw9vBjaqHwuLy93d02PSBmounWjzEyO2RKA7/PR+5ooewIH7ceYo40yxaW2rJnGvXvjpLiuk7ZXM6/TFHQ+qQ==} + '@anthropic-ai/claude-agent-sdk@0.3.228': + resolution: {integrity: sha512-OOaME54VCoBLjKMqWqFmHkZGyL/x/FHUA0snhyolmyEhVoeBM0Ub5mrnV2Gx3d5/RcVlk2BnEVvPqu0SpZ9VFw==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -1646,129 +1646,124 @@ packages: '@octokit/types@17.0.0': resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==} - '@oxc-parser/binding-android-arm-eabi@0.142.0': - resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} + '@oxc-parser/binding-android-arm-eabi@0.143.0': + resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.142.0': - resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} + '@oxc-parser/binding-android-arm64@0.143.0': + resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.142.0': - resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} + '@oxc-parser/binding-darwin-arm64@0.143.0': + resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.142.0': - resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} + '@oxc-parser/binding-darwin-x64@0.143.0': + resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.142.0': - resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} + '@oxc-parser/binding-freebsd-x64@0.143.0': + resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': - resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': - resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.142.0': - resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.142.0': - resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': - resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': - resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.142.0': - resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.142.0': - resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.142.0': - resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.142.0': - resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} + '@oxc-parser/binding-linux-x64-musl@0.143.0': + resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.142.0': - resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} + '@oxc-parser/binding-openharmony-arm64@0.143.0': + resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.142.0': - resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.142.0': - resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.142.0': - resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.142.0': - resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1776,8 +1771,8 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxc-project/types@0.142.0': - resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} @@ -2836,12 +2831,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.112.2': - resolution: {integrity: sha512-l1InCp4j98d09LZ6+RgubgF4eVPGBGXcLEhFusLg1qUCHJ2IEkYu5FohKK+eaFmIOwEk0kqG/j/lycw5e15mcQ==} + '@supabase/auth-js@2.112.3': + resolution: {integrity: sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.112.2': - resolution: {integrity: sha512-oMuSWN0ERmrG9S6kOM0bwhHmESGVl3kMtkZl2dNCU/r89hMiziX4GfD1omNo9QcBDele4N0GwSZ7hdbpuiA35A==} + '@supabase/functions-js@2.112.3': + resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} engines: {node: '>=22.0.0'} '@supabase/pg-delta@1.0.0-alpha.42': @@ -2860,20 +2855,20 @@ packages: '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.112.2': - resolution: {integrity: sha512-ewhhtRny/HFRGhUTTg/PsqIatsl8OhW8Eha/Tz4S+SRAXBnuhKei9ZpsQTgL/3XcH9UEwuPQyQgQ9itq7nRQeg==} + '@supabase/postgrest-js@2.112.3': + resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.112.2': - resolution: {integrity: sha512-cd9/CEUJ6Go13FxtfiuC5rYELJtuQzVzTXlGG+XjSppjDS+anq+xo++WQe7ZRUNTuHOCeyKRwmx9Hw/OQJ04ig==} + '@supabase/realtime-js@2.112.3': + resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.112.2': - resolution: {integrity: sha512-6jyBq/J1iXOHNpbjCZS7gFcDk49iM1MCJUVkDl71gLd/+XnLDzpUBs8icGebtwiHpl4kVszxIRDYAosbF4Rsig==} + '@supabase/storage-js@2.112.3': + resolution: {integrity: sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.112.2': - resolution: {integrity: sha512-UyI1epU9B4X51HvNpkmlwTdF20fEcz2vyvrcDKVzFN4jZN41f5iQRqsiIQjAY5OVJD6ljqA/1g9JQeOTvFHpkA==} + '@supabase/supabase-js@2.112.3': + resolution: {integrity: sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==} engines: {node: '>=22.0.0'} peerDependencies: '@opentelemetry/api': '>=1.0.0' @@ -3244,8 +3239,8 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} - '@vercel/detect-agent@1.2.4': - resolution: {integrity: sha512-euARTdvCVoi2k7/mqRUGyareO98jiVjwUyfJ8VFpN53aUv3R6ZgwSpU3ErOwFQKT6Fq8dOplEsy+DSmEoJPQdQ==} + '@vercel/detect-agent@1.2.5': + resolution: {integrity: sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==} engines: {node: '>=14'} '@verdaccio/auth@8.1.1': @@ -5070,8 +5065,8 @@ packages: keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} - knip@6.32.1: - resolution: {integrity: sha512-mIiIHMTJVUgSlz0mxEgPt7wg8DmfbCp1Txqab3WpbMCJF7YHvHtC9jeAHHXfISMl72N8WzhyG71SQlaqCOGZtg==} + knip@6.32.2: + resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5812,8 +5807,8 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - oxc-parser@0.142.0: - resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} + oxc-parser@0.143.0: + resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.24.2: @@ -6541,8 +6536,8 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - smol-toml@1.7.1: - resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} sonic-boom@3.8.1: @@ -6899,8 +6894,8 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - unbash@4.0.4: - resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==} + unbash@4.0.10: + resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} engines: {node: '>=14'} undici-types@7.16.0: @@ -7315,44 +7310,44 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.227': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.227': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.227': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.227': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.227': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.227': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.227': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.227': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.227(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.116.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.227 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.227 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.227 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.227 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.227 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.227 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.227 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.227 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.228 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.228 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.228 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.228 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.228 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.228 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.228 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.228 '@anthropic-ai/sdk@0.116.0(zod@4.4.3)': dependencies: @@ -8248,73 +8243,66 @@ snapshots: dependencies: '@octokit/openapi-types': 28.0.0 - '@oxc-parser/binding-android-arm-eabi@0.142.0': + '@oxc-parser/binding-android-arm-eabi@0.143.0': optional: true - '@oxc-parser/binding-android-arm64@0.142.0': + '@oxc-parser/binding-android-arm64@0.143.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.142.0': + '@oxc-parser/binding-darwin-arm64@0.143.0': optional: true - '@oxc-parser/binding-darwin-x64@0.142.0': + '@oxc-parser/binding-darwin-x64@0.143.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.142.0': + '@oxc-parser/binding-freebsd-x64@0.143.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.142.0': + '@oxc-parser/binding-linux-arm64-musl@0.143.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.142.0': + '@oxc-parser/binding-linux-x64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.142.0': + '@oxc-parser/binding-linux-x64-musl@0.143.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.142.0': - optional: true - - '@oxc-parser/binding-wasm32-wasi@0.142.0': - dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@oxc-parser/binding-openharmony-arm64@0.143.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.142.0': + '@oxc-parser/binding-win32-x64-msvc@0.143.0': optional: true '@oxc-project/types@0.139.0': {} - '@oxc-project/types@0.142.0': {} + '@oxc-project/types@0.143.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -9147,11 +9135,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.112.2': + '@supabase/auth-js@2.112.3': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.112.2': + '@supabase/functions-js@2.112.3': dependencies: tslib: 2.8.1 @@ -9175,27 +9163,27 @@ snapshots: '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.112.2': + '@supabase/postgrest-js@2.112.3': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.112.2': + '@supabase/realtime-js@2.112.3': dependencies: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.112.2': + '@supabase/storage-js@2.112.3': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.112.2': + '@supabase/supabase-js@2.112.3': dependencies: - '@supabase/auth-js': 2.112.2 - '@supabase/functions-js': 2.112.2 - '@supabase/postgrest-js': 2.112.2 - '@supabase/realtime-js': 2.112.2 - '@supabase/storage-js': 2.112.2 + '@supabase/auth-js': 2.112.3 + '@supabase/functions-js': 2.112.3 + '@supabase/postgrest-js': 2.112.3 + '@supabase/realtime-js': 2.112.3 + '@supabase/storage-js': 2.112.3 '@swc-node/core@1.15.0(@swc/core@1.15.47)(@swc/types@0.1.27)': dependencies: @@ -9472,7 +9460,7 @@ snapshots: '@ungap/structured-clone@1.3.3': {} - '@vercel/detect-agent@1.2.4': {} + '@vercel/detect-agent@1.2.5': {} '@verdaccio/auth@8.1.1': dependencies: @@ -11473,19 +11461,19 @@ snapshots: dependencies: '@keyv/serialize': 1.1.1 - knip@6.32.1: + knip@6.32.2: dependencies: fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 get-tsconfig: 4.14.1 jiti: 2.7.0 - oxc-parser: 0.142.0 + oxc-parser: 0.143.0 oxc-resolver: 11.24.2 picomatch: 4.0.5 - smol-toml: 1.7.1 + smol-toml: 1.8.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 - unbash: 4.0.4 + unbash: 4.0.10 yaml: 2.9.0 zod: 4.4.3 @@ -12474,30 +12462,29 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - oxc-parser@0.142.0: + oxc-parser@0.143.0: dependencies: - '@oxc-project/types': 0.142.0 + '@oxc-project/types': 0.143.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.142.0 - '@oxc-parser/binding-android-arm64': 0.142.0 - '@oxc-parser/binding-darwin-arm64': 0.142.0 - '@oxc-parser/binding-darwin-x64': 0.142.0 - '@oxc-parser/binding-freebsd-x64': 0.142.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 - '@oxc-parser/binding-linux-arm64-musl': 0.142.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 - '@oxc-parser/binding-linux-x64-gnu': 0.142.0 - '@oxc-parser/binding-linux-x64-musl': 0.142.0 - '@oxc-parser/binding-openharmony-arm64': 0.142.0 - '@oxc-parser/binding-wasm32-wasi': 0.142.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 - '@oxc-parser/binding-win32-x64-msvc': 0.142.0 + '@oxc-parser/binding-android-arm-eabi': 0.143.0 + '@oxc-parser/binding-android-arm64': 0.143.0 + '@oxc-parser/binding-darwin-arm64': 0.143.0 + '@oxc-parser/binding-darwin-x64': 0.143.0 + '@oxc-parser/binding-freebsd-x64': 0.143.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 + '@oxc-parser/binding-linux-arm64-musl': 0.143.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-musl': 0.143.0 + '@oxc-parser/binding-openharmony-arm64': 0.143.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 + '@oxc-parser/binding-win32-x64-msvc': 0.143.0 oxc-resolver@11.24.2: optionalDependencies: @@ -13426,7 +13413,7 @@ snapshots: smol-toml@1.6.1: {} - smol-toml@1.7.1: {} + smol-toml@1.8.0: {} sonic-boom@3.8.1: dependencies: @@ -13794,7 +13781,7 @@ snapshots: uint8array-extras@1.5.0: {} - unbash@4.0.4: {} + unbash@4.0.10: {} undici-types@7.16.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f9cd00427..ba2a607316 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,7 +26,7 @@ catalog: "@typescript/native-preview": "7.0.0-dev.20260707.2" "@vitest/coverage-istanbul": "^4.1.10" "effect": "4.0.0-beta.107" - "knip": "^6.32.1" + "knip": "^6.32.2" "nx": "^23.1.1" "oxfmt": "^0.63.0" "oxlint": "^1.78.0" From de06605e0cf3ba57156d296c0414dc87bb76d121 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:05:30 +0000 Subject: [PATCH 07/63] fix(cli): stream edge runtime bootstrap (CLI-2216) (#6273) ## TL;DR fixes `supabase start` and `functions serve` dying with edge-runtime's "failed to determine entrypoint" on Docker daemons that cannot see the client's filesystem (remote tcp `DOCKER_HOST` contexts, podman machines) - which was broken by #5712 moving the bootstrap `index.ts` out of the `sh -c` argv onto a single file host bind mount to fix the `Windows ENAMETOOLONG` crash - and #5847 making native `start` reuse that serve core which left the bind sourcing a host path such daemons silently materialize as an empty directory at `/root/index.ts` so now the bundled template is streamed into the created container with `docker cp` before `docker start`, the same delivery Kong, Postgres, and Supavisor secret files already use since: - #6022 which fixes bring-up on non local daemons while the earlier `ENAMETOOLONG` fix stays intact since nothing returns to the `spawn argv` and the template no longer stages on host disk at all.... ## ref: - closes: https://github.com/supabase/cli/issues/6254 --- .../commands/functions/serve/SIDE_EFFECTS.md | 42 ++-- .../functions/serve/serve.integration.test.ts | 218 ++++++++++-------- .../src/legacy/commands/start/SIDE_EFFECTS.md | 40 ++-- .../edge-runtime.service.integration.test.ts | 143 ++++++++++-- .../start/services/edge-runtime.service.ts | 37 +-- .../legacy/commands/start/start.handler.ts | 4 +- .../commands/start/start.integration.test.ts | 71 +++--- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 6 +- .../db-bootstrap/container-lifecycle.ts | 22 +- .../shared/legacy-start-secrets-cleanup.ts | 8 +- .../src/shared/functions/functions-docker.ts | 24 +- .../functions/functions-docker.unit.test.ts | 37 +++ apps/cli/src/shared/functions/serve.ts | 77 ++++--- .../src/shared/functions/serve.unit.test.ts | 4 +- 14 files changed, 494 insertions(+), 239 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index 82f8be8ae9..71d0d9a481 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -18,16 +18,18 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` | -| `/supabase-functions-serve-env-*/docker.env` | dotenv | per start, when single-line container env exists; passed via `--env-file`; mode `0600`; removed after the run | -| `/supabase-functions-serve-multiline-env-*/…` | shell + raw | per start, only when an env value contains a newline; bind-mounted read-only into the container; mode `0600`; removed after the run | +| Path | Format | When | +| ---------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` | +| `/supabase/.temp/start-secrets/supabase_edge_runtime_/env/docker.env` | dotenv | per start, when single-line container env exists; passed via `--env-file`; mode `0600`; removed after the run | +| `/supabase/.temp/start-secrets/supabase_edge_runtime_/multiline-env/…` | shell + raw | per start, only when an env value contains a newline; bind-mounted read-only into the container; mode `0600`; removed after the run | The env files hold secrets (JWT secret, anon/service-role keys, JWKS), so they are -written owner-only (`0600`) and cleaned up after the container exits. On `SIGKILL` -(which bypasses cleanup) a temp directory under `` may be orphaned; the OS -temp directory is the only place affected — the project directory is never modified. +written owner-only (`0600`, in `0700` directories) under the project's own +`supabase/.temp/` (gitignored) — a deterministic, persistent path rather than +`os.tmpdir()`, so `supabase stop` and a failed-start rollback can reclaim it via +`legacyCleanupStartSecrets` even when this command's own cleanup was bypassed +(e.g. `SIGKILL`). ## API Routes @@ -44,17 +46,17 @@ back to local keys. No scheme/host validation is performed on the discovered URL ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | -| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | -| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | -| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | -| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no | +| Variable | Purpose | Required? | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | +| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | +| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker create` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no | ## Exit Codes @@ -111,5 +113,5 @@ Long-running raw log / error events only; there is no terminal `result` event on - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted. -- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. +- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. - **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index ffc7631422..4dab2dafd3 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -422,7 +422,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -464,11 +464,11 @@ describe("legacy functions serve integration", () => { yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -509,7 +509,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -550,11 +550,11 @@ describe("legacy functions serve integration", () => { ); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -605,7 +605,7 @@ describe("legacy functions serve integration", () => { } expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(0); expect(deployMockState.networkCalls).toHaveLength(0); @@ -626,7 +626,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -704,11 +704,11 @@ describe("legacy functions serve integration", () => { expect(out.stderrText).toContain("Skipped serving Function: disabled\n"); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } expect(dockerRun.args).toContain("--network"); @@ -719,11 +719,21 @@ describe("legacy functions serve integration", () => { // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare pin stays // bare, no `v` synthesized. expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13"); + // The main service is `docker cp`-streamed in, never a single-file host bind (#6254). expect( extractFlagValues(dockerRun.args, "-v").some((value) => - value.endsWith(":/root/index.ts:ro,Z"), + value.includes(":/root/index.ts"), ), - ).toBe(true); + ).toBe(false); + const bringUpSteps = deployMockState.runCalls + .map((call) => call.args[0]) + .filter((step) => step === "create" || step === "cp" || step === "start"); + expect(bringUpSteps).toEqual(["create", "cp", "start"]); + expect(deployMockState.runCalls.map((call) => call.args.slice(0, 3))).toContainEqual([ + "cp", + "-", + "supabase_edge_runtime_test-project:/", + ]); expect(extractFlagValues(dockerRun.args, "--workdir")).toEqual([ toDockerPath(tempRoot.current), ]); @@ -790,7 +800,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -807,10 +817,10 @@ describe("legacy functions serve integration", () => { stderr: "error running container: exit 1", onSpawn: () => { const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); if (dockerRun === undefined) { - throw new Error("expected docker run call before docker logs spawn"); + throw new Error("expected docker create call before docker logs spawn"); } multilineEnvDirWhenLogsStarted = extractFlagValues(dockerRun.args, "-v") .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z")) @@ -849,11 +859,11 @@ describe("legacy functions serve integration", () => { expect(error).toBeInstanceOf(Error); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } expect(dockerRun.args).toContain( @@ -914,7 +924,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -967,11 +977,11 @@ describe("legacy functions serve integration", () => { expect(existsSync(staleMultilineEnvDir)).toBe(false); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } expect( extractFlagValues(dockerRun.args, "-v").some((value) => @@ -1010,7 +1020,7 @@ describe("legacy functions serve integration", () => { } expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(0); }); @@ -1055,7 +1065,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1110,7 +1120,7 @@ describe("legacy functions serve integration", () => { } expect( deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toBe(true); }); @@ -1127,7 +1137,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1185,11 +1195,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run invocation"); + throw new Error("expected docker create invocation"); } // `buildDockerBinds` realpath-resolves host paths, so compare against the // resolved path (on macOS the temp dir lives under /var -> /private/var). @@ -1215,7 +1225,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1284,11 +1294,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run invocation"); + throw new Error("expected docker create invocation"); } const resolvedSharedPath = realpathSync(sharedPath); expect( @@ -1312,7 +1322,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1345,9 +1355,9 @@ describe("legacy functions serve integration", () => { yield* waitFor( () => deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ).length === 1, - "timed out waiting for first docker run", + "timed out waiting for first docker create", ); fileWatcher.emit([ @@ -1369,7 +1379,7 @@ describe("legacy functions serve integration", () => { expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(2); // The file-change line prints the fsnotify op token Go prints @@ -1408,7 +1418,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1438,9 +1448,9 @@ describe("legacy functions serve integration", () => { yield* waitFor( () => deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), - "timed out waiting for docker run", + "timed out waiting for docker create", ); processControl.signal("SIGINT"); @@ -1554,7 +1564,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1629,7 +1639,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1668,11 +1678,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } expect(dockerRun.args).toContain("--network"); @@ -1699,7 +1709,7 @@ describe("legacy functions serve integration", () => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } return { exitCode: 0, stdout: "", stderr: "" }; @@ -1719,25 +1729,51 @@ describe("legacy functions serve integration", () => { yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; expect(commandScript).toBe( "edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", ); - expect( - extractFlagValues(dockerRun.args, "-v").some((value) => - value.endsWith(":/root/index.ts:ro,Z"), - ), - ).toBe(true); + + const cp = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "cp", + ); + expect(cp).toBeDefined(); + if (cp === undefined) { + throw new Error("expected docker cp call"); + } + const cpOptions: unknown = cp.options; + const stdin = + typeof cpOptions === "object" && cpOptions !== null && "stdin" in cpOptions + ? cpOptions.stdin + : undefined; + // Narrows the mock-recorded `unknown`; the `instanceof Uint8Array` check still guards. + const isCpArchiveStream = (value: unknown): value is Stream.Stream => + Stream.isStream(value); + expect(isCpArchiveStream(stdin)).toBe(true); + if (!isCpArchiveStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); + const chunks = yield* Stream.runCollect(stdin); + const archiveBytes = chunks[0]; + if (!(archiveBytes instanceof Uint8Array)) { + return yield* Effect.die("docker cp stdin did not contain archive bytes"); + } + const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); + const mainService = files.get("root/index.ts"); + if (mainService === undefined) { + return yield* Effect.die("docker cp archive did not contain root/index.ts"); + } + const template = yield* Effect.promise(() => mainService.text()); + expect(template.length).toBeGreaterThan(0); + expect(template).not.toContain("@ts-nocheck"); + expect(template).not.toContain("declare const Deno"); + expect(template).not.toContain("declare const EdgeRuntime"); expect(commandScript).not.toContain("@ts-nocheck"); - expect(commandScript).not.toContain("declare const Deno"); - expect(commandScript).not.toContain("declare const EdgeRuntime"); }); }); @@ -1746,7 +1782,7 @@ describe("legacy functions serve integration", () => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } return { exitCode: 0, stdout: "", stderr: "" }; @@ -1780,11 +1816,11 @@ describe("legacy functions serve integration", () => { ); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } expect(dockerRun.args).toContain("-p"); @@ -1804,7 +1840,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1882,11 +1918,11 @@ describe("legacy functions serve integration", () => { expect(fetchMock).toHaveBeenCalledTimes(2); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -1920,7 +1956,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -1970,11 +2006,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2010,7 +2046,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2062,11 +2098,11 @@ describe("legacy functions serve integration", () => { expect(fetchMock).not.toHaveBeenCalled(); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2096,7 +2132,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2140,11 +2176,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2168,7 +2204,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2210,11 +2246,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2236,7 +2272,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2314,7 +2350,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2382,11 +2418,11 @@ describe("legacy functions serve integration", () => { ); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2730,7 +2766,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2805,7 +2841,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2845,11 +2881,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2881,7 +2917,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -2925,11 +2961,11 @@ describe("legacy functions serve integration", () => { } const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -2974,7 +3010,7 @@ describe("legacy functions serve integration", () => { } expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(0); }); @@ -3026,7 +3062,7 @@ describe("legacy functions serve integration", () => { ]); expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(0); expect(deployMockState.networkCalls).toHaveLength(0); @@ -3095,7 +3131,7 @@ describe("legacy functions serve integration", () => { type: "warn", message: expect.stringContaining("An error occurred in Effect.tryPromise"), }); - expect(deployMockState.runCalls.filter((call) => call.args[0] === "run")).toHaveLength(0); + expect(deployMockState.runCalls.filter((call) => call.args[0] === "create")).toHaveLength(0); }); }); @@ -3174,7 +3210,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -3209,11 +3245,11 @@ describe("legacy functions serve integration", () => { yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun).toBeDefined(); if (dockerRun === undefined) { - throw new Error("expected docker run call"); + throw new Error("expected docker create call"); } expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); }); @@ -3233,7 +3269,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -3271,7 +3307,7 @@ describe("legacy functions serve integration", () => { { networkMode: "env-network", projectId: "test-project" }, ]); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun?.args).toContain("env-network"); }); @@ -3289,7 +3325,7 @@ describe("legacy functions serve integration", () => { if (args[0] === "container" && args[1] === "rm") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "run") { + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { @@ -3327,7 +3363,7 @@ describe("legacy functions serve integration", () => { { networkMode: "flag-network", projectId: "test-project" }, ]); const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ); expect(dockerRun?.args).toContain("flag-network"); expect(dockerRun?.args).not.toContain("env-network"); @@ -3361,7 +3397,7 @@ describe("legacy functions serve integration", () => { } expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(0); }); @@ -3396,7 +3432,7 @@ describe("legacy functions serve integration", () => { } expect( deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "run", + (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(0); }); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 05c794a899..4af5e52658 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -50,8 +50,10 @@ ran). Reuses `shared/functions/serve.ts`'s `startEdgeRuntimeContainer` core (the same one `functions serve` uses). Gated on `edge_runtime.enabled && !--exclude edge-runtime`, -started between ImgProxy and pg-meta in the container-start sequence. Unlike every other -service, it's a direct `docker run -d ...` (not `docker create`+`docker start`) and is +started between ImgProxy and pg-meta in the container-start sequence. Its own +`docker create` → `docker cp` (the bundled main-service template, streamed as a stdin +tar archive) → `docker start` sequence is assembled by that shared core, not by +`legacyCreateContainer` like every other service, and it is health-checked via an HTTP probe through Kong (`/functions/v1/_internal/health`), not a Docker healthcheck — mirroring PostgREST's own probe shape. @@ -91,11 +93,11 @@ command. ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | -| `/supabase/.temp/start-secrets//{env,multiline-env,main}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts and bootstrap template — see below | -| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `start` | +| Path | Format | When | +| ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | +| `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below | +| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `start` | Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own bootstrap script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected @@ -121,17 +123,27 @@ since the content already lives inside the container's own filesystem. Studio reads/writes SQL snippets under `/supabase/snippets/` at its own runtime — that's Studio's behavior, not something `start` itself writes. -Edge Runtime's own JWT/service-role-key/configured-secret env file, multiline-env -script + value files, and bootstrap `index.ts` template (`shared/functions/serve.ts`'s -`writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) are +Edge Runtime's own JWT/service-role-key/configured-secret env file and multiline-env +script + value files (`shared/functions/serve.ts`'s +`writeDockerEnvFile`/`writeDockerMultilineEnvScript`) are staged on host disk under `/supabase/.temp/start-secrets//{env,multiline-env,main}/` (directory mode `0700`, files mode `0600`), +containerName>/{env,multiline-env}/` (directory mode `0700`, files mode `0600`) — the +env file is read client-side by `--env-file`, the multiline-env directory is bind-mounted `:ro,Z` into the container — a deterministic, persistent path rather than `os.tmpdir()` (which is frequently tmpfs and gets wiped on reboot) so `legacyCleanupStartSecrets` (see the Exit Codes/rollback section below) can reclaim -them on `stop` or a failed-start rollback. Each of the three writers removes and +them on `stop` or a failed-start rollback. Each writer removes and recreates its own subdirectory fresh on every call (self-healing), so a -shrinking env set never leaves stale files behind. +shrinking env set never leaves stale files behind. The bootstrap `index.ts` template +carries no secret content and, as of supabase/cli#6254, never touches host disk at all: +it is streamed via `docker cp` straight into the created (not yet started) Edge Runtime +container — a single-file host bind mount materializes as an empty directory on daemons +that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, +podman machines), which broke `start` with edge-runtime's "failed to determine +entrypoint". Only the bootstrap template is daemon-independent: user function +sources under `supabase/functions/**` and the multiline-env script directory +(present only when a secret value contains a newline) still arrive by host bind +mounts and require a daemon that can see the project directory. ## API Routes @@ -321,7 +333,7 @@ prose, not structured data. `/supabase/.temp/start-secrets/` directory. As of supabase/cli#6022 this is a no-op for a removed Kong/Postgres/Supavisor container (nothing under its own directory anymore); it still matters for a removed Edge Runtime - container, whose own env-file/multiline-env-script/serve-main-template staging is + container, whose own env-file/multiline-env-script staging is unaffected by that change. - Docker status `created` is not considered a recoverable stopped stack: the container and named volume are preserved because the volume may not have completed its first database diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts index abba2db0e8..79ae42c489 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { beforeEach } from "vitest"; import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -16,32 +16,44 @@ import { /** * A spawner that answers every `docker` invocation * `legacyStartEdgeRuntimeContainer`'s call chain makes - * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the `docker run -d` bring-up - * itself) with success, recording every invocation's argv for assertions — - * same shape as `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret - * values are delivered to the `run -d` call via `--env-file`/a bind-mounted + * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the create → cp → start + * bring-up itself) with success, recording every invocation's argv (plus its + * `stdin` option, for the `docker cp` archive) for assertions — same shape as + * `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret + * values are delivered to the `create` call via `--env-file`/a bind-mounted * script, not this spawned process's own environment (see * `edge-runtime.service.ts`'s header for why), so there is nothing to capture - * beyond argv. Note `legacyStartEdgeRuntimeContainer` never issues a + * beyond argv and stdin. Note `legacyStartEdgeRuntimeContainer` never issues a * `docker exec ... kong reload` — that only happens in `functions serve`'s own * `restartEdgeRuntime`-equivalent wrapper (`shared/functions/serve.ts`'s * `startEdgeRuntime`), not in the shared bring-up core this module calls * directly — see the "does not reload Kong" test below. */ -function mockDockerSpawner() { - const calls: Array<{ args: ReadonlyArray }> = []; +function mockDockerSpawner( + handler?: (args: ReadonlyArray) => { exitCode: number; stderr?: string }, +) { + const calls: Array<{ + args: ReadonlyArray; + stdin: ChildProcess.CommandInput | ChildProcess.StdinConfig | undefined; + }> = []; + const encoder = new TextEncoder(); const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { const args = command._tag === "StandardCommand" ? command.args : []; - calls.push({ args }); + calls.push({ + args, + stdin: command._tag === "StandardCommand" ? command.options.stdin : undefined, + }); + const result = handler?.(args) ?? { exitCode: 0 }; const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: Stream.empty, - stderr: Stream.empty, + stderr: + result.stderr === undefined ? Stream.empty : Stream.make(encoder.encode(result.stderr)), all: Stream.empty, exitCode: Deferred.await(exitDeferred), isRunning: Effect.succeed(false), @@ -60,7 +72,8 @@ function mockDockerSpawner() { return calls; }, get runCall() { - return calls.find((call) => call.args[0] === "run" && call.args[1] === "-d"); + // The container-level `docker create` (`docker volume create` starts with "volume"). + return calls.find((call) => call.args[0] === "create"); }, }; } @@ -281,6 +294,108 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect( + "delivers the bundled main service via docker cp into the created container — never a single-file host bind (#6254)", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const containerSteps = mock.calls + .map((call) => call.args[0]) + .filter((step) => step === "create" || step === "cp" || step === "start"); + expect(containerSteps).toEqual(["create", "cp", "start"]); + + // No bind delivers /root/index.ts — the single-file host bind is what broke #6254. + const createArgs = mock.runCall!.args; + const bindValues = createArgs.flatMap((arg, index) => + createArgs[index - 1] === "-v" ? [arg] : [], + ); + expect(bindValues.some((bind) => bind.includes(":/root/index.ts"))).toBe(false); + + const cp = mock.calls.find((call) => call.args[0] === "cp"); + expect(cp?.args).toEqual(["cp", "-", "supabase_edge_runtime_proj:/"]); + const stdin = cp?.stdin; + expect(Stream.isStream(stdin)).toBe(true); + if (!Stream.isStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); + const chunks = yield* Stream.runCollect(stdin); + expect(chunks).toHaveLength(1); + const archiveBytes = chunks[0]; + if (!(archiveBytes instanceof Uint8Array)) { + return yield* Effect.die("docker cp stdin did not contain archive bytes"); + } + const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); + expect([...files.keys()]).toEqual(["root/index.ts"]); + const mainService = files.get("root/index.ts"); + if (mainService === undefined) { + return yield* Effect.die("docker cp archive did not contain the main service"); + } + expect(yield* Effect.promise(() => mainService.text())).toContain( + "SUPABASE_INTERNAL_FUNCTIONS_CONFIG", + ); + }), + ); + + it.effect( + "surfaces docker's own stderr verbatim and never reaches cp/start when docker create fails", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner((args) => + args[0] === "create" + ? { exitCode: 125, stderr: "Conflict. The container name is already in use" } + : { exitCode: 0 }, + ); + const out = mockOutput(); + + const error = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain("Conflict. The container name is already in use"); + expect(mock.calls.some((call) => call.args[0] === "cp")).toBe(false); + expect(mock.calls.some((call) => call.args[0] === "start")).toBe(false); + expect(mock.calls.some((call) => call.args[0] === "container")).toBe(false); + }), + ); + + it.effect( + "prefixes docker cp's uninterpretable stderr, never reaches docker start, and leaves container removal to the caller when the copy fails", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner((args) => + args[0] === "cp" + ? { + exitCode: 1, + stderr: 'destination "supabase_edge_runtime_proj:/" must be a directory', + } + : { exitCode: 0 }, + ); + const out = mockOutput(); + + const error = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain( + 'failed to copy edge runtime main service into container: destination "supabase_edge_runtime_proj:/" must be a directory', + ); + expect(mock.calls.some((call) => call.args[0] === "start")).toBe(false); + // Removal stays with the callers, matching `docker run -d` behavior. + expect(mock.calls.some((call) => call.args[0] === "container")).toBe(false); + }), + ); + it.effect( "resolves with the started container's id and a cleanup effect left for the caller to run", () => @@ -299,7 +414,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { ); it.effect( - "cleans up a stale staging directory from a previous invocation even when this invocation fails before ever reaching docker run", + "cleans up a stale staging directory from a previous invocation even when this invocation fails before ever reaching docker create", () => Effect.gen(function* () { const mock = mockDockerSpawner(); @@ -322,7 +437,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { // A multiline secret with a name that fails `validateDockerMultilineEnvNames` (must // match a shell variable name) — this throws before any of THIS invocation's staging // writes happen, proving cleanup covers the whole staging-write window, not just a - // failure at (or after) the `docker run` step. + // failure at (or after) the docker create/cp/start steps. edgeRuntimeSecrets: { "1BAD_NAME": "line one\nline two" }, }; diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts index 7a9dbf3e97..e283f0c822 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -23,12 +23,15 @@ * containing a newline (`docker env-file` is a line-oriented format that * cannot represent one) — a second env-delivery mechanism the shared spec * has no concept of. - * - `docker run -d` (one atomic call) vs. `docker create` + `docker start` - * (two calls) is a structural argv difference, not a flag-mapping detail. + * - Edge Runtime's `docker create` → `docker cp` → `docker start` sequence + * (`shared/functions/serve.ts`) superficially resembles + * `legacyCreateContainer`'s, but its `docker cp` payload is the bundled + * main-service template (not `secretFiles`), so the shared spec's + * secret-file contract does not map onto it. * - * So this module keeps `startEdgeRuntimeContainer`'s direct `docker run -d` - * exactly as `functions serve` already spawns it (see that module's own doc - * comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct + * So this module keeps `startEdgeRuntimeContainer`'s own create/cp/start + * bring-up exactly as `functions serve` already spawns it (see that module's + * own doc comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct * bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop * — NOT a spec for `legacyCreateContainer` to create. `start.handler.ts`'s * wiring must special-case Edge Runtime's bring-up call, the same way it @@ -121,7 +124,7 @@ export interface LegacyEdgeRuntimeBringUpInput { /** * Bring up the Edge Runtime container for `start`, delegating the entire - * `docker run` argv/env assembly to `shared/functions/serve.ts`'s + * create/cp/start argv/env assembly to `shared/functions/serve.ts`'s * `startEdgeRuntimeContainer` (already ported for `functions serve`) with * `start`'s own already-resolved config/secrets in place of that command's * independent config-loading pipeline. `start.handler.ts`'s bring-up loop @@ -136,27 +139,27 @@ export interface LegacyEdgeRuntimeBringUpInput { * shape as the existing `postgrest` gateway). `watchSpecs` is * `functions serve`-only file-watch plumbing and can be ignored here. * - * `cleanup` (removing the temp env-file/multiline-env-script/serve-main- - * template files this call writes to the host) is intentionally left to the + * `cleanup` (removing the temp env-file/multiline-env-script files this call + * writes to the host) is intentionally left to the * caller, and the caller must NOT invoke it on a successful bring-up. Unlike * every other `start` service (`legacyCreateContainer`'s `restartPolicy: * "unless-stopped"`), Edge Runtime's bring-up sets NO Docker restart policy * at all — its lifecycle is deliberately reconciled at the CLI level, not - * the Docker daemon level — so this container's own `docker run` + * the Docker daemon level — so this container's own bring-up * (`shared/functions/serve.ts`) intentionally omits `--restart` too. Its * bind-mounted host paths must * still exist for as long as the container itself can be reattached to * (e.g. a plain `docker start` by the user, or discovery by a later CLI - * invocation) — unlike Kong/Postgres/Supavisor's `secretFiles`, which - * `container-lifecycle.ts`'s `legacyCreateContainer` now streams straight - * into the container instead of staging on host disk (see - * `legacyCopyStartSecretFilesIntoContainer`'s doc comment), Edge Runtime's own - * bind-mounted env-file/multiline-env-script/serve-main-template artifacts - * still need this host persistence, since `docker run -d` bind-mounts them - * rather than copying their content in. `startEdgeRuntimeContainer` (`shared/functions/ + * invocation) — unlike Kong/Postgres/Supavisor's `secretFiles` and Edge + * Runtime's own bootstrap template, which are `docker cp`-streamed straight + * into the created container instead of staged on host disk (see + * `legacyCopyStartSecretFilesIntoContainer`'s doc comment and + * `startEdgeRuntimeContainer`'s own delivery comment), Edge Runtime's + * bind-mounted multiline-env-script artifacts still need this host + * persistence. `startEdgeRuntimeContainer` (`shared/functions/ * serve.ts`) already runs `cleanup` internally on any failed or interrupted * bring-up (`Effect.onError`, covering the whole staging-write-through- - * `docker run` window, not just a non-zero exit code), so the caller only + * `docker start` window, not just a non-zero exit code), so the caller only * needs to leave the returned `cleanup` unused on success. */ export const legacyStartEdgeRuntimeContainer = Effect.fn("legacy.start.edgeRuntime")(function* ( diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 934ee7fd31..7d92e89285 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1756,8 +1756,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // `edge-runtime.service.ts`'s header for why. Unlike every other // service built here (`legacyCreateContainer`'s `restartPolicy: // "unless-stopped"`), Edge Runtime's own bring-up sets no Docker - // restart policy at all, so this container's `docker run` matches - // that — but its bind-mounted host temp files must still exist for + // restart policy at all — but its bind-mounted host temp files + // (env-file/multiline-env-script staging) must still exist for // as long as the container can be reattached to; `legacyStart // EdgeRuntimeContainer` already runs `cleanup` on a failed or // interrupted bring-up internally, so only the success path must diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 3a73df184d..4ea6ca0b73 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -226,6 +226,11 @@ function containerNameFromCreateArgs(args: ReadonlyArray): string { return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown"; } +/** Edge Runtime's own create/cp/start bring-up sits outside `legacyCreateContainer`; its create is recognized by the `_edge_runtime_` container name. */ +function isEdgeRuntimeCreate(args: ReadonlyArray): boolean { + return args[0] === "create" && containerNameFromCreateArgs(args).includes("_edge_runtime_"); +} + /** * Real `docker create` prints a 64-hex id, never the `--name`. The mock does * too, so a caller that carries that opaque id into the health watch fails the @@ -240,8 +245,10 @@ function fakeContainerId(name: string): string { } function createdContainerNames(spawned: ReadonlyArray): ReadonlyArray { + // Excludes Edge Runtime's create so this keeps meaning "which services + // `legacyCreateContainer` brought up", the premise of the exact-equality assertions below. return spawned - .filter((s) => s.args[0] === "create") + .filter((s) => s.args[0] === "create" && !isEdgeRuntimeCreate(s.args)) .map((s) => containerNameFromCreateArgs(s.args)); } @@ -494,8 +501,9 @@ function setup(opts: SetupOpts = {}) { * matrix test below. `storage-api` is compound: excluding it also disables * ImgProxy (`start.gates.ts`'s `imgproxy: storage && ...` dependency). * `edge-runtime` maps to no suffix at all here — it DOES really start now - * (`legacyStartEdgeRuntimeContainer`, a direct `docker run -d`, never a - * `docker create`), so `--exclude edge-runtime` is exercised by its own + * (`legacyStartEdgeRuntimeContainer`, its own create/cp/start bring-up outside + * `legacyCreateContainer`, which `createdContainerNames` excludes), so + * `--exclude edge-runtime` is exercised by its own * dedicated scenarios below rather than through this `docker create`-based * matrix. */ @@ -2380,7 +2388,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("fresh volume: DB setup + bucket seeding", () => { - /** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls) — a plain `docker run --rm ...`, never `-d`, distinct from Edge Runtime's own detached `docker run -d`. */ + /** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls) — a plain `docker run --rm ...`, distinct from Edge Runtime's own create/cp/start bring-up. */ function dbSetupJobCalls(spawned: ReadonlyArray): ReadonlyArray { return spawned.filter((s) => s.args[0] === "run" && s.args[1] === "--rm"); } @@ -2752,20 +2760,31 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("edge runtime", () => { - /** Edge Runtime's own bring-up (`legacyStartEdgeRuntimeContainer`) is a direct, detached `docker run -d ...`, never a `docker create`+`docker start` pair like every other service. */ + /** Edge Runtime's own bring-up (`legacyStartEdgeRuntimeContainer`) is a `docker create` → `docker cp` (main-service archive) → `docker start` sequence; its create is the one naming the `_edge_runtime_` container, distinguishing it from every other service's `legacyCreateContainer` create. */ function edgeRuntimeRunCalls(spawned: ReadonlyArray): ReadonlyArray { - return spawned.filter((s) => s.args[0] === "run" && s.args[1] === "-d"); + return spawned.filter((s) => isEdgeRuntimeCreate(s.args)); } - it.live("starts a real container via docker run -d when enabled and not excluded", () => { + it.live("creates and starts a real container when enabled and not excluded", () => { const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); const runCalls = edgeRuntimeRunCalls(child.spawned); expect(runCalls).toHaveLength(1); - expect(runCalls[0]?.args).toContain("--name"); const nameIndex = runCalls[0]?.args.indexOf("--name") ?? -1; - expect(runCalls[0]?.args[nameIndex + 1]).toContain("_edge_runtime_"); + const containerName = runCalls[0]?.args[nameIndex + 1] ?? ""; + expect(containerName).toContain("_edge_runtime_"); + // The main service is `docker cp`-streamed in, never a single-file host bind (#6254). + const bindValues = (runCalls[0]?.args ?? []).flatMap((arg, index) => + runCalls[0]?.args[index - 1] === "-v" ? [arg] : [], + ); + expect(bindValues.some((bind) => bind.includes(":/root/index.ts"))).toBe(false); + expect(child.spawned.map((s) => s.args.slice(0, 3))).toContainEqual([ + "cp", + "-", + `${containerName}:/`, + ]); + expect(child.spawned.map((s) => s.args)).toContainEqual(["start", containerName]); }).pipe(Effect.provide(layer)); }); @@ -2778,26 +2797,24 @@ content_path = "./supabase/templates/custom_notice.html" }); it.live( - "keeps the host-side bind-mount temp files after a successful bring-up (no eager cleanup)", + "keeps the host-side staged env artifacts after a successful bring-up (no eager cleanup)", () => { - // Staged under `/supabase/.temp/start-secrets//main/` - // — a deterministic, persistent path (not `os.tmpdir()`), so a later - // `stop`/rollback can reclaim it via `legacyCleanupStartSecrets`. See - // the "reclaims Edge Runtime's own temp secret artifacts on stop" test - // below for that cleanup behavior. + // Staged under `/supabase/.temp/start-secrets//` so a later + // `stop`/rollback can reclaim it; the bootstrap template is no longer part of it. const { layer, child, workdir } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); const runArgs = edgeRuntimeRunCalls(child.spawned)[0]?.args ?? []; - const bindValues = runArgs.flatMap((arg, i) => (runArgs[i - 1] === "-v" ? [arg] : [])); + const envFileIndex = runArgs.indexOf("--env-file"); + const envFilePath = envFileIndex === -1 ? undefined : runArgs[envFileIndex + 1]; + expect(envFilePath).toBeDefined(); const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); - const mainTemplateBind = bindValues.find( - (bind) => bind.startsWith(stagingRoot) && bind.includes(`${join("main", "index.ts")}:`), - ); - expect(mainTemplateBind).toBeDefined(); - const hostPath = mainTemplateBind?.split(":")[0] ?? ""; + expect(envFilePath?.startsWith(stagingRoot)).toBe(true); try { - expect(existsSync(hostPath)).toBe(true); + expect(existsSync(envFilePath ?? "")).toBe(true); + // `//env/docker.env` → the staging dir is two levels up. + const containerStagingDir = join(envFilePath ?? "", "..", ".."); + expect(existsSync(join(containerStagingDir, "main"))).toBe(false); } finally { rmSync(stagingRoot, { recursive: true, force: true }); } @@ -4816,9 +4833,7 @@ content_path = "./supabase/templates/custom_notice.html" }); return Effect.gen(function* () { yield* legacyStart(flags()); - const edgeRuntimeRunCall = child.spawned.find( - (s) => s.args[0] === "run" && s.args[1] === "-d", - ); + const edgeRuntimeRunCall = child.spawned.find((s) => isEdgeRuntimeCreate(s.args)); const args = edgeRuntimeRunCall?.args ?? []; const envFileIndex = args.indexOf("--env-file"); const envFilePath = envFileIndex !== -1 ? args[envFileIndex + 1] : undefined; @@ -4849,9 +4864,7 @@ content_path = "./supabase/templates/custom_notice.html" }); return Effect.gen(function* () { yield* legacyStart(flags()); - const edgeRuntimeRunCall = child.spawned.find( - (s) => s.args[0] === "run" && s.args[1] === "-d", - ); + const edgeRuntimeRunCall = child.spawned.find((s) => isEdgeRuntimeCreate(s.args)); const args = edgeRuntimeRunCall?.args ?? []; const envFileIndex = args.indexOf("--env-file"); const envFilePath = envFileIndex !== -1 ? args[envFileIndex + 1] : undefined; @@ -5493,7 +5506,7 @@ content_path = "./supabase/templates/custom_notice.html" }); return Effect.gen(function* () { yield* legacyStart(flags()); - const runCalls = child.spawned.filter((s) => s.args[0] === "run" && s.args[1] === "-d"); + const runCalls = child.spawned.filter((s) => isEdgeRuntimeCreate(s.args)); const entrypointCommand = runCalls[0]?.args.at(-1) ?? ""; expect(entrypointCommand).toContain("--policy=per_worker"); expect(entrypointCommand).not.toContain("--policy=oneshot"); diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 472e6c1b6d..8d3a0d30c4 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -22,9 +22,9 @@ The `start-secrets` removal is a TS-port-only hygiene step (`legacyCleanupStartS `legacy/shared/legacy-start-secrets-cleanup.ts`) — the old Go CLI never staged secrets on host disk in the first place, so it has nothing to clean up here. Only Edge Runtime's own JWT/service-role-key/secret env artifacts (`shared/functions/serve.ts`'s -`writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) still -land on host disk this way, because that container is a `docker run` this port shells out -to directly rather than a struct call over the Docker Engine API; without this cleanup that +`writeDockerEnvFile`/`writeDockerMultilineEnvScript`) still +land on host disk this way, because that container's bring-up shells out to the docker +CLI directly rather than a struct call over the Docker Engine API; without this cleanup that directory would survive `stop` indefinitely. (Kong's TLS/`kong.yml`, Postgres's pgsodium root key, and Supavisor's pooler tenant-script content are delivered via `docker cp` straight into the created container instead — as of supabase/cli#6022 they never touch diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 2714e8bad2..828737b3d1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -34,7 +34,10 @@ import { } from "../legacy-docker-bind-classify.ts"; import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "../legacy-docker-ids.ts"; import { legacyIsDockerDaemonUnreachable } from "../legacy-docker-suggest.ts"; -import { isUserDefinedDockerNetwork } from "../../../shared/functions/functions-docker.ts"; +import { + containerArchiveBytes, + isUserDefinedDockerNetwork, +} from "../../../shared/functions/functions-docker.ts"; import { legacyBuildStartContainerCreateArgs, legacyApplyBitbucketStartContainerFilter, @@ -162,8 +165,8 @@ export interface LegacyContainerOpts { * {@link legacyCopyStartSecretFilesIntoContainer}), so they never touch host disk at * all. This label is still load-bearing for OTHER host-persisted staging under the same * `/supabase/.temp/start-secrets//` tree that this function - * itself never writes — e.g. Edge Runtime's own env-file/multiline-env-script/serve- - * main-template staging (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`), + * itself never writes — e.g. Edge Runtime's own env-file/multiline-env-script + * staging (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`), * which `legacyCleanupStartSecrets` (`legacy-start-secrets-cleanup.ts`) still reclaims * by this same label once that container is torn down. */ @@ -743,14 +746,13 @@ function legacyCopyStartSecretFilesIntoContainer( ): Effect.Effect { if (secretFiles.length === 0) return Effect.void; - const entries = Object.fromEntries( - secretFiles.map((secretFile) => [ - secretFile.containerPath.replace(/^\/+/, ""), - secretFile.content, - ]), - ); return Effect.tryPromise({ - try: () => new Bun.Archive(entries).bytes(), + try: () => + containerArchiveBytes( + Object.fromEntries( + secretFiles.map((secretFile) => [secretFile.containerPath, secretFile.content]), + ), + ), catch: (cause) => new LegacyContainerCreateError({ message: `failed to create docker container: failed to prepare container secret files: ${ diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts index 42731f2cbe..476279c58a 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts @@ -22,10 +22,10 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * comment), so a bind mount's host-side path never has to be resolved by a * remote Docker daemon. This module remains load-bearing for Edge Runtime's * OWN, still-host-persisted staging under the exact same tree - * (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer` — a `docker run - * -d`, not `docker create`+`docker start`, which bind-mounts its env-file/ - * multiline-env-script/serve-main-template artifacts rather than copying - * their content in) — this function has no way to distinguish which + * (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`, whose env-file + * and bind-mounted multiline-env-script artifacts stay on host disk; only its + * bootstrap template is `docker cp`-streamed in) — this function has no way + * to distinguish which * producer staged a given container's directory, nor does it need to: a * directory that was never staged in the first place is a harmless no-op (see * below). diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index d5872ea2c2..999fc633d1 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -82,6 +82,26 @@ export function toDockerPath(hostPath: string) { return normalized.replace(/^[A-Za-z]:/, ""); } +/** + * In-memory tar bytes for `docker cp - :/` delivery, which needs no + * daemon-visible host path. Keys are absolute container paths; leading slashes + * are stripped into the tar entry names since the archive extracts at the + * container root. `Bun.Archive` exposes no per-entry mode option, so entries + * carry its `0644` default. + */ +export function containerArchiveBytes( + files: Readonly>, +): Promise { + return new Bun.Archive( + Object.fromEntries( + Object.entries(files).map(([containerPath, content]) => [ + containerPath.replace(/^\/+/, ""), + content, + ]), + ), + ).bytes(); +} + export interface FunctionsDockerRunSpec { /** Already registry/pull-resolved image reference. */ readonly image: string; @@ -175,6 +195,8 @@ export const runChildProcess = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, opts: { + /** Streamed to the child's stdin (e.g. a `docker cp -` tar archive); defaults to no stdin. */ + readonly stdin?: Stream.Stream; readonly stdout?: "pipe" | "ignore"; readonly stderr?: "pipe" | "ignore"; readonly env?: Readonly>; @@ -189,7 +211,7 @@ export const runChildProcess = Effect.fnUntraced(function* ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawnContainerCli(spawner, [...args], { - stdin: "ignore", + stdin: opts.stdin ?? "ignore", stdout: opts.stdout ?? "pipe", stderr: opts.stderr ?? "pipe", env: opts.env, diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 60eaef8ea2..947c1a001b 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -4,6 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { buildFunctionsDockerRunArgs, + containerArchiveBytes, localDockerId, resolveDockerNetworkMode, runChildProcess, @@ -181,6 +182,42 @@ describe("buildFunctionsDockerRunArgs", () => { }); }); +describe("containerArchiveBytes", () => { + // Regular-file tar entries parsed straight from the ustar headers. + function tarRegularFileEntries(archive: Uint8Array): ReadonlyArray<[string, number]> { + const decoder = new TextDecoder(); + const parseOctal = (field: Uint8Array) => + Number.parseInt(decoder.decode(field).replaceAll("\0", "").trim() || "0", 8); + const entries: Array<[string, number]> = []; + let offset = 0; + + while (offset + 512 <= archive.byteLength) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + + const type = header[156]; + if (type === 0 || type === 0x30) { + const name = decoder.decode(header.subarray(0, 100)).replaceAll("\0", ""); + entries.push([name, parseOctal(header.subarray(100, 108))]); + } + + const size = parseOctal(header.subarray(124, 136)); + offset += 512 + Math.ceil(size / 512) * 512; + } + + return entries; + } + + it("strips leading slashes into root-relative tar entries with the contractual 0644 mode", async () => { + const archive = await containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" }); + // The 0644 mode is contractual — a Bun default change must fail here, not as a + // runtime permission error inside the container. + expect(tarRegularFileEntries(archive)).toEqual([["root/index.ts", 0o644]]); + const files = await new Bun.Archive(archive).files(); + expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n"); + }); +}); + describe("resolveDockerNetworkMode", () => { it("prefers the explicit flag over the env override when both are set", () => { expect( diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 8d15ec157b..0db4d04c47 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -57,6 +57,7 @@ import { type ResolvedDeployFunctionConfig, } from "./deploy.ts"; import { + containerArchiveBytes, dockerProjectLabels, ensureDockerNamedVolume, ensureDockerNetwork, @@ -1409,6 +1410,30 @@ const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: stri }).pipe(Effect.ignore); }); +// One step of Edge Runtime's create → cp → start bring-up. Only the cp step passes a +// `messagePrefix` — its raw stderr is uninterpretable alone — while create/start keep the +// `docker run -d` era stderr surface byte-identical. +const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( + args: ReadonlyArray, + opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream } = {}, +) { + const result = yield* runChildProcess("docker", args, { + stdin: opts.stdin, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + const message = + opts.messagePrefix === undefined + ? detail || "failed to start edge runtime" + : detail.length > 0 + ? `${opts.messagePrefix}: ${detail}` + : opts.messagePrefix; + return yield* Effect.fail(new Error(message)); + } +}); + const reloadKong = Effect.fnUntraced(function* (projectId: string) { const output = yield* Output; const kongId = localDockerId("kong", projectId); @@ -1445,18 +1470,6 @@ export function buildServeEntrypointCommand( `; } -async function writeServeMainTemplateFile(template: string, dir: string) { - // Mount the bundled runtime template instead of embedding it in `sh -c` so - // Windows does not hit `uv_spawn` ENAMETOOLONG on path-heavy projects. - // Self-healing — see the matching comment in `writeDockerEnvFile` above. - await rm(dir, { recursive: true, force: true }); - await mkdir(dir, { recursive: true, mode: 0o700 }); - const pathname = join(dir, "index.ts"); - await writeFile(pathname, template); - // `Z` — same SELinux relabel rationale as `writeDockerMultilineEnvScript`'s bind. - return { bind: `${pathname}:${serveMainContainerPath}:ro,Z` } as const; -} - const resolveServeFunctionConfigs = Effect.fnUntraced(function* ( projectRoot: string, supabaseDir: string, @@ -1592,11 +1605,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // ` tree keyed by container name, so these JWT/service-role-key/secret env // artifacts no longer leak on host disk indefinitely after the container is torn down. const stagingDir = join(input.projectRoot, "supabase", ".temp", "start-secrets", containerId); - // A single directory-wide `rm` rather than three per-file `.cleanup()` closures (the JWT - // secrets/env file, the multiline-env script, the serve-main template all live under - // `stagingDir`): this is what lets the cleanup cover the whole staging-write window below, - // including a mid-write failure between the first and second `writeDocker*` call, not just - // the final `docker run` step. + // A single directory-wide `rm` rather than per-file `.cleanup()` closures (the JWT + // secrets/env file and the multiline-env script both live under `stagingDir`): this is + // what lets the cleanup cover the whole staging-write window below, including a mid-write + // failure between the first and second `writeDocker*` call, not just the final docker + // create/cp/start steps. const removeRuntimeArtifacts = Effect.tryPromise({ try: () => rm(stagingDir, { recursive: true, force: true }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), @@ -1690,7 +1703,8 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo partitionDockerEnvEntries(dockerEnv); // Everything from here on writes into `stagingDir` (or starts the container that reads from // it), so the whole window — including a mid-write failure between two `writeDocker*` calls, - // not just the final `docker run` step — is wrapped in `Effect.onError` below. + // not just the final docker create/cp/start steps — is wrapped in `Effect.onError` below. + // Container removal on failure stays with the callers, matching `docker run -d` behavior. return yield* Effect.gen(function* () { yield* Effect.try({ try: () => validateDockerMultilineEnvNames(multilineDockerEnv), @@ -1722,14 +1736,16 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...(input.debug ? ["--verbose"] : []), ]; const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); - const serveMainTemplateFile = yield* Effect.tryPromise({ - try: () => writeServeMainTemplateFile(serveMainTemplate, join(stagingDir, "main")), + // Streamed in via `docker cp` between create and start: embedding the template in the + // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as + // an empty directory on daemons that cannot see this host's filesystem (#6254, #4190). + const serveMainArchive = yield* Effect.tryPromise({ + try: () => containerArchiveBytes({ [serveMainContainerPath]: serveMainTemplate }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }); const containerProjectRoot = toDockerPath(input.projectRoot); const command = [ - "run", - "-d", + "create", "--name", containerId, "--network", @@ -1745,8 +1761,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo `com.docker.compose.project=${labels["com.docker.compose.project"]}`, "--label", `${dockerWorkdirLabel}=${input.projectRoot}`, - "-v", - serveMainTemplateFile.bind, ...([...binds] as ReadonlyArray).flatMap((bind) => ["-v", bind]), ...(dockerMultilineEnvScript === undefined ? [] : ["-v", dockerMultilineEnvScript.bind]), ...(dockerEnvFile === undefined ? [] : ["--env-file", dockerEnvFile.path]), @@ -1761,15 +1775,14 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), ]; - const result = yield* runChildProcess("docker", command, { - stdout: "pipe", - stderr: "pipe", + // The container must exist for `docker cp` to have a target, and must not be running + // yet so edge-runtime never races the copy. + yield* runEdgeRuntimeDockerStep(command); + yield* runEdgeRuntimeDockerStep(["cp", "-", `${containerId}:/`], { + messagePrefix: "failed to copy edge runtime main service into container", + stdin: Stream.make(serveMainArchive), }); - if (result.exitCode !== 0) { - const message = - result.stderr.trim() || result.stdout.trim() || "failed to start edge runtime"; - return yield* Effect.fail(new Error(message)); - } + yield* runEdgeRuntimeDockerStep(["start", containerId]); return { containerId, diff --git a/apps/cli/src/shared/functions/serve.unit.test.ts b/apps/cli/src/shared/functions/serve.unit.test.ts index 5194857742..cd4d3af2d6 100644 --- a/apps/cli/src/shared/functions/serve.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.unit.test.ts @@ -42,8 +42,8 @@ describe("dockerBindContainerPath", () => { }); it("strips the SELinux relabel suffix this file emits", () => { - expect(dockerBindContainerPath("/tmp/x/main/index.ts:/root/index.ts:ro,Z")).toBe( - "/root/index.ts", + expect(dockerBindContainerPath("/tmp/x/multiline-env:/root/.supabase/multiline-env:ro,Z")).toBe( + "/root/.supabase/multiline-env", ); }); From a7cf00d66c39b6dc516bcc873219574b0a5979e2 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:05:58 +0000 Subject: [PATCH 08/63] chore(api): sync Management API OpenAPI spec (#6272) This PR was automatically created to sync the generated `@supabase/api` package with the latest Management API OpenAPI document. Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. Co-authored-by: jgoux <1443499+jgoux@users.noreply.github.com> --- packages/api/src/generated/contracts.ts | 5 +---- packages/api/src/generated/openapi.json | 10 +--------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index f5b30cf176..d649e50f8a 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -4031,6 +4031,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "project_restore_after_expiry", "assistant.advance_model", "integrations.github_connections", + "integrations.github_push_webhooks_limit", "dedicated_pooler", "observability.dashboard_advanced_metrics", "api.members.invitations", @@ -10662,7 +10663,6 @@ export const V2DeployAWorkerInput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - backend: Schema.optionalKey(Schema.String), }), context_upload_id: Schema.optionalKey( Schema.String.annotate({ @@ -10693,7 +10693,6 @@ export const V2DeployAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - backend: Schema.optionalKey(Schema.String), }), build_state: Schema.Literals(["building", "active", "failed"]), secret_generation: Schema.String, @@ -10788,7 +10787,6 @@ export const V2GetAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - backend: Schema.optionalKey(Schema.String), }), build_state: Schema.Literals(["building", "active", "failed"]), secret_generation: Schema.String, @@ -11471,7 +11469,6 @@ export const V2ListAllWorkersOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - backend: Schema.optionalKey(Schema.String), }), build_state: Schema.Literals(["building", "active", "failed"]), secret_generation: Schema.String, diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 0f32121440..2130e09552 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -22678,6 +22678,7 @@ "project_restore_after_expiry", "assistant.advance_model", "integrations.github_connections", + "integrations.github_push_webhooks_limit", "dedicated_pooler", "observability.dashboard_advanced_metrics", "api.members.invitations", @@ -24870,9 +24871,6 @@ "minimum": -9007199254740991, "maximum": 9007199254740991, "example": 1 - }, - "backend": { - "type": "string" } }, "required": ["size", "exposure", "instances"] @@ -24971,9 +24969,6 @@ "minimum": -9007199254740991, "maximum": 9007199254740991, "example": 1 - }, - "backend": { - "type": "string" } }, "required": ["size", "exposure", "instances"] @@ -25106,9 +25101,6 @@ "minimum": -9007199254740991, "maximum": 9007199254740991, "example": 1 - }, - "backend": { - "type": "string" } }, "required": ["size", "exposure", "instances"] From dc97151e51eca112de94c5b9bfe102ee798b7caf Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 19 Aug 2026 16:14:50 +0000 Subject: [PATCH 09/63] fix(cli): run DROP INDEX CONCURRENTLY outside the migration transaction (CLI-2218) (#6276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes [CLI-2218](https://linear.app/supabase/issue/CLI-2218/support-drop-index-concurrently-in-migrations). The v2.109.0 fix for pipeline-incompatible statements (#5671, design from #5156) classifies statements that cannot run inside a transaction block and runs them standalone outside the migration batch. Its pattern list covers `CREATE [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, and `CLUSTER` — but not `DROP INDEX CONCURRENTLY`, which was missed. A migration containing one still gets batched into the implicit transaction and PostgreSQL rejects it: ``` ERROR: DROP INDEX CONCURRENTLY cannot run inside a transaction block (SQLSTATE 25001) ``` This adds the missing `DROP INDEX CONCURRENTLY` pattern to `legacyIsPipelineIncompatible` in the TS legacy shell, and mirrors it in the Go sidecar's `isPipelineIncompatible` (`pkg/migration/file.go`), which is still reachable through the remaining Go-delegated paths that apply migrations (`db remote commit`, `db branch`). Reported by an enterprise customer running `db push` on v2.111.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 --- apps/cli-go/pkg/migration/file.go | 2 ++ apps/cli-go/pkg/migration/file_test.go | 15 +++++++++++++++ .../src/legacy/shared/legacy-migration-apply.ts | 7 +++++-- .../shared/legacy-migration-apply.unit.test.ts | 7 +++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/cli-go/pkg/migration/file.go b/apps/cli-go/pkg/migration/file.go index d7526ac4dd..e74d322c78 100644 --- a/apps/cli-go/pkg/migration/file.go +++ b/apps/cli-go/pkg/migration/file.go @@ -30,6 +30,7 @@ var ( migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`) createIndexPattern = regexp.MustCompile(`^CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY(\s|\z)`) + dropIndexPattern = regexp.MustCompile(`^DROP\s+INDEX\s+CONCURRENTLY(\s|\z)`) reindexPattern = regexp.MustCompile(`^REINDEX(\s|\().*\sCONCURRENTLY(\s|\z)`) vacuumPattern = regexp.MustCompile(`^VACUUM(\s|\(|\z)`) alterSystemPattern = regexp.MustCompile(`^ALTER\s+SYSTEM(\s|\z)`) @@ -80,6 +81,7 @@ func NewMigrationFromReader(sql io.Reader) (*MigrationFile, error) { func isPipelineIncompatible(sql string) bool { upper := strings.ToUpper(trimLeadingSQLComments(sql)) return createIndexPattern.MatchString(upper) || + dropIndexPattern.MatchString(upper) || reindexPattern.MatchString(upper) || vacuumPattern.MatchString(upper) || alterSystemPattern.MatchString(upper) || diff --git a/apps/cli-go/pkg/migration/file_test.go b/apps/cli-go/pkg/migration/file_test.go index 49fb0f7f68..83087ba0e6 100644 --- a/apps/cli-go/pkg/migration/file_test.go +++ b/apps/cli-go/pkg/migration/file_test.go @@ -240,6 +240,16 @@ func TestIsPipelineIncompatible(t *testing.T) { sql: "-- cannot run in a transaction\n/* generated */\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", want: true, }, + { + name: "drop index concurrently", + sql: "DROP INDEX CONCURRENTLY public.widgets_id_idx", + want: true, + }, + { + name: "drop index concurrently if exists", + sql: "drop index concurrently if exists api.idx_rx_orders_clinic_id", + want: true, + }, { name: "reindex table concurrently", sql: "REINDEX TABLE CONCURRENTLY public.widgets", @@ -275,6 +285,11 @@ func TestIsPipelineIncompatible(t *testing.T) { sql: "CREATE INDEX widgets_id_idx ON public.widgets(id)", want: false, }, + { + name: "ordinary drop index", + sql: "DROP INDEX IF EXISTS public.widgets_id_idx", + want: false, + }, { name: "concurrently in string literal", sql: "SELECT 'CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)'", diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index f19e292a1c..f343028926 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -59,6 +59,7 @@ const BOM_CODE_POINT = 0xfeff; // pipeline-incompatible here but wouldn't under that narrower definition. Not worth // changing behaviour over — flagging so a future review doesn't rediscover it. const CREATE_INDEX_CONCURRENTLY_PATTERN = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY(?:\s|$)/u; +const DROP_INDEX_CONCURRENTLY_PATTERN = /^DROP\s+INDEX\s+CONCURRENTLY(?:\s|$)/u; const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/u; const VACUUM_PATTERN = /^VACUUM(?:\s|\(|$)/u; const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; @@ -95,8 +96,9 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { /** * Whether a migration statement cannot run inside a transaction block — `CREATE - * [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, - * `CLUSTER`. Such statements fail with SQLSTATE 25001 inside the implicit transaction + * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, + * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`. Such statements fail with SQLSTATE 25001 + * inside the implicit transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). */ @@ -104,6 +106,7 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { const upper = legacyTrimLeadingSqlComments(sql).toUpperCase(); return ( CREATE_INDEX_CONCURRENTLY_PATTERN.test(upper) || + DROP_INDEX_CONCURRENTLY_PATTERN.test(upper) || REINDEX_CONCURRENTLY_PATTERN.test(upper) || VACUUM_PATTERN.test(upper) || ALTER_SYSTEM_PATTERN.test(upper) || diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 9bd46cf31d..0bacc45136 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1072,6 +1072,12 @@ describe("legacyIsPipelineIncompatible", () => { "-- cannot run in a transaction\n/* generated */\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", true, ], + ["drop index concurrently", "DROP INDEX CONCURRENTLY public.widgets_id_idx", true], + [ + "drop index concurrently if exists", + "drop index concurrently if exists api.idx_rx_orders_clinic_id", + true, + ], ["reindex table concurrently", "REINDEX TABLE CONCURRENTLY public.widgets", true], [ "reindex with options concurrently", @@ -1091,6 +1097,7 @@ describe("legacyIsPipelineIncompatible", () => { ["bom before vacuum", "\uFEFFVACUUM", true], // Negatives — compatible statements that must keep running inside the batch transaction. ["plain create index", "CREATE INDEX widgets_id_idx ON public.widgets(id)", false], + ["plain drop index", "DROP INDEX IF EXISTS public.widgets_id_idx", false], [ "concurrently in string literal", "SELECT 'CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)'", From 85df55e9c50b259375110e27240a75a0ffad3fe6 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 19 Aug 2026 16:15:31 +0000 Subject: [PATCH 10/63] ci: run the develop CI suite on stacked and draft PRs via a run-ci label (#6275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Ready PRs targeting `develop` already get Test, preview CLI packages, and PR-title lint. Stacked PRs (base is another branch) never enter those workflows, and drafts skip the jobs. This adds a `run-ci` label that calls the same Test and preview suites as reusable workflows, including while the PR is still a draft. Ready `develop` PRs stay on the existing workflows so required check names are unchanged. - Add `run-ci` to start the suite; remove it to cancel in-progress opt-in runs. - Other labels do not start or cancel Test / preview. - Independent of `run-live-e2e-ci`. - After a stacked PR is retargeted onto `develop`, push or reopen so the native required checks populate. The `run-ci` repository label already exists. ## Linked issue N/A — maintainer CI workflow (exempt). --- .github/MAINTAINERS.md | 23 +++++++ .github/workflows/lint-pull-request.yml | 9 ++- .../publish-preview-cli-packages.yml | 24 +++++-- .github/workflows/run-ci.yml | 67 +++++++++++++++++++ .github/workflows/test.yml | 34 +++++++++- 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/run-ci.yml diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index e8321dd3b9..15ef191ead 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -48,6 +48,29 @@ function; create it once from **Issues → Labels** if it is missing. Applying `open-for-contribution` is currently a **manual step** — do it on the GitHub issue directly (from the GitHub UI, or from the Linear-linked issue). +## `run-ci`: full develop CI on stacked or draft PRs + +Ready (non-draft) PRs targeting `develop` already get the default suite: Test +(check / unit+integration / e2e), preview CLI packages, and PR-title lint. + +Stacked PRs (base is another PR branch) and drafts do **not** get that suite +unless they carry the **`run-ci`** label. [`run-ci.yml`](./workflows/run-ci.yml) +then calls Test and preview-package publish as reusable workflows, including +while the PR is still a draft. + +- Add `run-ci` to start (or resume) the suite; remove it to cancel in-progress + `run-ci` runs via that workflow's concurrency group. +- Other labels do not start or cancel Test / preview. PR-title lint may + retrigger because that check is cheap. +- After a stacked PR is retargeted onto `develop`, push or reopen so the + native required checks (`Check code quality`, etc.) populate. The opt-in + suite uses different check names (`Test / Check code quality`). +- This is independent of `run-live-e2e-ci`, which opts into the separate + supabox live e2e dispatch. + +The `run-ci` label must exist as a repository label; create it from +**Issues → Labels** if it is missing. + ## Deferred: automatic Linear → GitHub label sync We considered auto-applying `open-for-contribution` when a Linear issue moves out of diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml index a2ba597315..c70377a830 100644 --- a/.github/workflows/lint-pull-request.yml +++ b/.github/workflows/lint-pull-request.yml @@ -2,6 +2,10 @@ name: Lint Pull Request # Release-notes PRs (head ref `release-notes/*`) skip CI; only # apply-release-notes.yml runs for those. +# +# Draft PRs skip this check unless they carry `run-ci` (same opt-in as Test +# and preview packages). Label events retrigger this cheap check so adding +# `run-ci` on a draft starts lint without waiting for a push. on: pull_request_target: types: @@ -10,6 +14,8 @@ on: - synchronize - reopened - ready_for_review + - labeled + - unlabeled merge_group: types: - checks_requested @@ -29,7 +35,8 @@ jobs: github.event_name == 'merge_group' || (github.event_name == 'pull_request_target' && !startsWith(github.event.pull_request.head.ref, 'release-notes/') && - github.event.pull_request.draft == false) + (github.event.pull_request.draft == false || + contains(github.event.pull_request.labels.*.name, 'run-ci'))) name: Lint Pull Request runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index afbc6ec537..5282341687 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -2,6 +2,9 @@ name: Publish Preview CLI Packages # Release-notes PRs (head ref `release-notes/*`) are markdown-only and are not # meant to produce installable preview packages. +# +# Default path: ready (non-draft) PRs targeting `develop`. +# `run-ci.yml` calls this workflow for drafts and stacked / non-develop PRs. on: pull_request: types: @@ -9,22 +12,35 @@ on: - synchronize - reopened - ready_for_review + - converted_to_draft branches: - develop + workflow_call: + inputs: + force: + description: Publish even when the PR is a draft (used by run-ci.yml) + type: boolean + default: false + secrets: + DF_FIREWALL_TOKEN: + required: true permissions: actions: read contents: read +# Literal prefix: called workflows inherit github.workflow from the caller +# (`run-ci`). `inputs.force` separates this call from a skipped native run on +# a develop draft. concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} + group: publish-preview-cli-packages.yml-${{ github.event.pull_request.number || github.head_ref }}-${{ inputs.force && 'run-ci' || 'direct' }} cancel-in-progress: true jobs: build: if: | !startsWith(github.head_ref, 'release-notes/') && - github.event.pull_request.draft == false + (inputs.force || github.event.pull_request.draft == false) name: Build preview CLI packages uses: ./.github/workflows/build-cli-artifacts.yml with: @@ -37,7 +53,7 @@ jobs: needs: build if: | !startsWith(github.head_ref, 'release-notes/') && - github.event.pull_request.draft == false && + (inputs.force || github.event.pull_request.draft == false) && needs.build.result == 'success' name: Publish preview package runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -121,7 +137,7 @@ jobs: needs: publish if: | !startsWith(github.head_ref, 'release-notes/') && - github.event.pull_request.draft == false && + (inputs.force || github.event.pull_request.draft == false) && needs.publish.result == 'success' name: Post preview command comment runs-on: ubuntu-latest diff --git a/.github/workflows/run-ci.yml b/.github/workflows/run-ci.yml new file mode 100644 index 0000000000..c3dffb9788 --- /dev/null +++ b/.github/workflows/run-ci.yml @@ -0,0 +1,67 @@ +name: run-ci + +# Opt-in full develop CI for PRs that Test.yml / preview do not already cover: +# stacked PRs (base is not develop) and drafts. Ready develop PRs stay on the +# existing workflows so required check names and concurrency are unchanged. +# +# Add the `run-ci` label to start the suite; remove it to cancel in-progress +# runs via this workflow's concurrency group. Other labels do not retrigger. +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - labeled + - unlabeled + - converted_to_draft + +permissions: + actions: read + contents: read + pull-requests: write + +# Unrelated label events still start a run; give them a unique group so they +# cannot cancel an in-progress suite. Removing `run-ci` stays on the main +# group and cancels via cancel-in-progress. +concurrency: + group: >- + run-ci.yml-${{ github.event.pull_request.number || github.ref }}${{ + ((github.event.action == 'labeled' || github.event.action == 'unlabeled') + && github.event.label.name != 'run-ci' + && format('-noop-{0}', github.run_id)) + || '' + }} + cancel-in-progress: true + +jobs: + test: + name: Test + if: | + !startsWith(github.head_ref, 'release-notes/') && + contains(github.event.pull_request.labels.*.name, 'run-ci') && + (github.event.pull_request.draft || github.base_ref != 'develop') && + ((github.event.action != 'labeled' && + github.event.action != 'unlabeled') || + github.event.label.name == 'run-ci') + uses: ./.github/workflows/test.yml + with: + force: true + secrets: + DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} + + preview: + name: Preview packages + if: | + !startsWith(github.head_ref, 'release-notes/') && + contains(github.event.pull_request.labels.*.name, 'run-ci') && + (github.event.pull_request.draft || github.base_ref != 'develop') && + ((github.event.action != 'labeled' && + github.event.action != 'unlabeled') || + github.event.label.name == 'run-ci') + uses: ./.github/workflows/publish-preview-cli-packages.yml + with: + force: true + secrets: + DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0d4754442..daaf76f68d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,9 @@ name: Test # Release-notes PRs (head ref `release-notes/*`) only add markdown under # `release-notes/` and are published via approval — skip the full CI suite. +# +# Default path: ready (non-draft) PRs targeting `develop`, plus the merge queue. +# `run-ci.yml` calls this workflow for drafts and stacked / non-develop PRs. on: pull_request: types: @@ -9,6 +12,7 @@ on: - synchronize - reopened - ready_for_review + - converted_to_draft branches: - develop merge_group: @@ -16,13 +20,26 @@ on: - checks_requested branches: - develop + workflow_call: + inputs: + force: + description: Run the suite even when the PR is a draft (used by run-ci.yml) + type: boolean + default: false + secrets: + DF_FIREWALL_TOKEN: + required: true permissions: contents: read actions: read +# Literal prefix: called workflows inherit github.workflow from the caller +# (`run-ci`), which would cancel the caller and the preview call. `inputs.force` +# keeps a skipped native Test run on a develop draft from cancelling the +# forced run-ci call. concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: test.yml-${{ github.event.pull_request.number || github.head_ref || github.ref }}-${{ inputs.force && 'run-ci' || 'direct' }} cancel-in-progress: true jobs: @@ -30,6 +47,7 @@ jobs: if: | !startsWith(github.head_ref, 'release-notes/') && (github.event_name == 'merge_group' || + inputs.force || github.event.pull_request.draft == false) name: Check code quality runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -53,6 +71,7 @@ jobs: if: | !startsWith(github.head_ref, 'release-notes/') && (github.event_name == 'merge_group' || + inputs.force || github.event.pull_request.draft == false) name: Run unit and integration tests runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -76,6 +95,7 @@ jobs: if: | !startsWith(github.head_ref, 'release-notes/') && (github.event_name == 'merge_group' || + inputs.force || github.event.pull_request.draft == false) name: Run end-to-end tests (shard ${{ matrix.shard }}/3) runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -90,9 +110,18 @@ jobs: fetch-depth: 0 - name: Set base and head SHAs for affected - if: github.event_name == 'pull_request' + if: github.event.pull_request && !inputs.force uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 + # Reusable calls inherit the caller's github context (event_name stays + # pull_request). Use the PR payload directly so we do not depend on + # nx-set-shas understanding the caller event. + - name: Set base and head SHAs for run-ci reusable call + if: inputs.force && github.event.pull_request + run: | + echo "NX_BASE=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_ENV" + echo "NX_HEAD=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV" + - name: Set base and head SHAs for merge queue affected if: github.event_name == 'merge_group' run: | @@ -173,6 +202,7 @@ jobs: always() && !startsWith(github.head_ref, 'release-notes/') && (github.event_name == 'merge_group' || + inputs.force || github.event.pull_request.draft == false) name: Run end-to-end tests needs: test-e2e From 8fc5f3da8f959bda4fd9058313e5e26d578102f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:16:10 +0000 Subject: [PATCH 11/63] chore(ci): bump the actions-major group with 3 updates (#6279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 3 updates: [jdx/mise-action](https://github.com/jdx/mise-action), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `jdx/mise-action` from 4.2.4 to 4.2.5
Release notes

Sourced from jdx/mise-action's releases.

v4.2.5: Resilient mise downloads with automatic retries

A small patch release that makes setup more resilient to transient network failures when downloading mise.

Fixed

Retry mise downloads after transient failures (#597 by @​jdx)

The download helpers previously made a single curl or wget attempt, so a transient GitHub release-asset HTTP or TLS failure would abort setup before mise or any user command could run (see #596).

Downloads now run through a retry wrapper that makes up to five attempts with a 2s pause between failures, logging a warning on each retry. This applies consistently to binary, checksum, signature, and version fetches. Checksum and minisign verification still run only after a successful download — never inside the retry loop — so integrity guarantees are unchanged.

Full Changelog: https://github.com/jdx/mise-action/compare/v4.2.4...v4.2.5

Changelog

Sourced from jdx/mise-action's changelog.

Changelog


4.2.5 - 2026-08-12

🐛 Bug Fixes


4.2.4 - 2026-08-01

🐛 Bug Fixes

  • locking support detection with force-colored output (#580) by @​scop in #580

4.2.3 - 2026-07-24

🐛 Bug Fixes


4.2.2 - 2026-07-24

🐛 Bug Fixes

📚 Documentation

New Contributors


4.2.1 - 2026-07-16

🐛 Bug Fixes

🔍 Other Changes

... (truncated)

Commits
  • 3c2e0cf chore: release v4.2.5 (#598)
  • 9dda395 fix: retry mise downloads after transient failures (#597)
  • 9d2b311 chore(deps): update github/codeql-action action to v4.37.6 (#593)
  • 4213fbb chore(deps): update jdx/mise-action action to v4.2.4 (#594)
  • 672dbd2 chore(deps): update zizmorcore/zizmor-action action to v0.6.2 (#595)
  • 5159765 chore(deps): lock file maintenance (#592)
  • c75b4f6 chore(deps): update jdx/mise-action action to v4.2.3 (#590)
  • 367cc0d chore(deps): update github/codeql-action action to v4.37.3 (#589)
  • 90e6e66 chore(deps): update zizmorcore/zizmor-action action to v0.6.1 (#591)
  • 7350bb8 chore(deps): lock file maintenance (#588)
  • See full diff in compare view

Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085
Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

... (truncated)

Commits
  • ff2f1c6 Merge pull request #4093 from github/update-v4.37.7-be7a3dbb8
  • 951a133 Update changelog for v4.37.7
  • be7a3db Merge pull request #4087 from github/dependabot/npm_and_yarn/npm-minor-0aa561...
  • 9310334 Merge pull request #4086 from github/mbg/thread-action-state-to-codeql
  • b4d8a54 Rebuild
  • ab5db25 Bump the npm-minor group across 1 directory with 8 updates
  • 38055a3 Drop logger from databaseInitCluster in interface
  • 1f87aed Merge pull request #4085 from github/update-bundle/codeql-bundle-v2.26.3
  • dc1b98a Make logger available to getCodeQLForCmd
  • 6f0220e Merge pull request #4084 from github/navntoft/bump-undici
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085
Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

... (truncated)

Commits
  • ff2f1c6 Merge pull request #4093 from github/update-v4.37.7-be7a3dbb8
  • 951a133 Update changelog for v4.37.7
  • be7a3db Merge pull request #4087 from github/dependabot/npm_and_yarn/npm-minor-0aa561...
  • 9310334 Merge pull request #4086 from github/mbg/thread-action-state-to-codeql
  • b4d8a54 Rebuild
  • ab5db25 Bump the npm-minor group across 1 directory with 8 updates
  • 38055a3 Drop logger from databaseInitCluster in interface
  • 1f87aed Merge pull request #4085 from github/update-bundle/codeql-bundle-v2.26.3
  • dc1b98a Make logger available to getCodeQLForCmd
  • 6f0220e Merge pull request #4084 from github/navntoft/bump-undici
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-ci.yml | 2 +- .github/workflows/cli-go-codeql.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 79b8aa19be..0591972abe 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -91,7 +91,7 @@ jobs: with: persist-credentials: false - - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4 + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4 with: version: 2026.7.0 install: true diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 00a2a7b2a7..7e51715cb8 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: "/language:${{matrix.language}}" defaults: From 3b4d6ec6500ee55a2e1b70f37ee4867eed22da6b Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:06:54 +0000 Subject: [PATCH 12/63] test(stack): deflake readiness (#6282) ## TL;DR fixes the flaky stack readiness tests by replacing the live clock readiness race with deterministic virtual time and an owned 503 health endpoint.... ## ref: - spotted on: [job 96378991208](https://github.com/supabase/cli/actions/runs/32353950826/job/96378991208) & [job 95913000393](https://github.com/supabase/cli/actions/runs/32200465815/job/95913000393) --- packages/stack/src/Stack.unit.test.ts | 62 ++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 1eb8a84283..603a1407de 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -7,6 +7,7 @@ import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { StackBuildError } from "./errors.ts"; @@ -883,13 +884,54 @@ describe("Stack", () => { }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); - it.live("uses the stack readiness deadline for explicit lazy activation and cleans up", () => + it.effect("uses the stack readiness deadline for explicit lazy activation and cleans up", () => Effect.gen(function* () { - const spawner = mockChildProcessSpawner(); + const authHealthServer = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response("unhealthy", { status: 503 }), + }), + ), + (server) => Effect.sync(() => server.stop(true)), + ); + const authPort = authHealthServer.port; + if (authPort === undefined) { + throw new Error("Expected the auth health test server to bind a TCP port"); + } + const authConfig = defaultConfig.auth; + if (authConfig === false) { + throw new Error("Expected auth to be enabled in the default test config"); + } + const postgresProbeStarted = yield* Deferred.make(); + const postgresInitStarted = yield* Deferred.make(); + const authSpawnStarted = yield* Deferred.make(); + const spawner = mockChildProcessSpawner({ + beforeSpawn: (record) => { + if (record.command.endsWith("/pg_isready")) { + return Deferred.succeed(postgresProbeStarted, undefined).pipe(Effect.asVoid); + } + if ( + record.args.some((arg) => + Buffer.from(arg, "base64url").toString().includes('"command":"bash","args":["-c"'), + ) + ) { + return Deferred.succeed(postgresInitStarted, undefined).pipe(Effect.asVoid); + } + return record.args.some((arg) => + Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), + ) + ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.asVoid) + : Effect.void; + }, + }); let releasedAll = false; const config = { ...defaultConfig, startupMode: "lazy", + ports: { ...defaultPorts, authPort }, + auth: { ...authConfig, port: authPort }, readiness: { mode: "finite", timeoutMs: 100 }, readinessSource: "configured", } satisfies ResolvedStackConfig; @@ -904,9 +946,19 @@ describe("Stack", () => { yield* Effect.gen(function* () { const stack = yield* Stack; const activator = yield* StackServiceActivator; - yield* stack.start(); + const start = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(postgresProbeStarted); + yield* TestClock.adjust("10 millis"); + yield* Deferred.await(postgresInitStarted); + yield* TestClock.adjust("89 millis"); + yield* Fiber.join(start); - const error = yield* activator.activate("auth").pipe(Effect.flip); + const activation = yield* activator + .activate("auth") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(authSpawnStarted); + yield* TestClock.adjust("100 millis"); + const error = yield* Fiber.join(activation).pipe(Effect.flip); expect(error._tag).toBe("StackReadinessError"); if (error._tag === "StackReadinessError") { @@ -931,7 +983,7 @@ describe("Stack", () => { yield* stack.stop(); expect(spawner.spawned).toHaveLength(spawnCountAfterDisposal); }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + }).pipe(Effect.scoped), ); it.live("allows a finite wait override against an infinite stack policy", () => From 6aa759615e80c4fca85f35123e5e09fd7ff0d51d Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 20 Aug 2026 13:34:05 +0000 Subject: [PATCH 13/63] docs(cli): make the TS legacy shell the source of truth in agent instructions (#6281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Updates the repo agent instructions (`AGENTS.md` files and the delegation-surface doc) to reflect where the CLI actually stands after the Go→TS migration: - **`src/legacy/` is the source of truth.** The rewritten `apps/cli/AGENTS.md` replaces the "Go CLI Authority" framing with a "Source of Truth" section: the compatibility standard is the legacy shell's own established behavior (tests, `SIDE_EFFECTS.md`, shipped output), not comparison against Go. It also explicitly disallows new comments/docs/helper names framed as "Go parity", with old framing cleaned up opportunistically. - **`next/` is frozen and departing.** The shell-architecture section now documents that `next/` development moves to its own branch and the folder will leave this tree; no new features land there. Porting-era guidance built around it ("Reusing next/ implementations") is removed, and the dual-write file-location rule is noted as leaving with `next/`. - **Go is a residual delegation surface, not a reference.** "Phase 0: Go Binary Wrapper" becomes "The Go Delegation Surface": the surface only shrinks, never grows, and `apps/cli-go/` is authoritative solely for the still-proxied commands' flag definitions until they are removed. `docs/go-cli-porting-status.md` and `apps/cli-e2e/AGENTS.md` get matching one-line reframes. - **Still-live invariants are kept, de-Go'd.** The parity checklist becomes "Legacy Shell Invariants" and the telemetry section now treats `shared/telemetry/event-catalog.ts` as canonical in its own right; all operational rules (telemetry ensuring, stderr error shape, `--debug` log format, `-o` vs `--output-format`, CLI-1546 spinner rule) survive unchanged. - **`docs/go-cli-divergences.md` is declared a frozen historical record** — new flags/features are just new CLI behavior and no longer tracked as divergences. Docs-only change; no runtime behavior is affected. ## Linked issue Closes # - [x] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). ## Checklist - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). - [ ] Tests added or updated for the change. (Docs-only — not applicable.) - [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. (No code touched; no markdown checks in CI.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- apps/cli-e2e/AGENTS.md | 2 +- apps/cli/AGENTS.md | 337 +++++++++++-------------- apps/cli/docs/binary-distribution.md | 4 +- apps/cli/docs/go-cli-divergences.md | 12 +- apps/cli/docs/go-cli-porting-status.md | 7 +- 5 files changed, 163 insertions(+), 199 deletions(-) diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index a2d36c076a..b309111a1f 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -200,7 +200,7 @@ is a single-job operation; parallel shards would race on the shared ## Go binary version requirement -The ts-legacy CLI proxies a fixed, small set of commands to a Go binary (`SUPABASE_GO_BINARY` → bundled package binary → system `supabase`) — as of CLI-1970, `apps/cli-go/` contains only that residual proxied subset, nothing else. If your system `supabase` binary predates a flag or subcommand change on one of these, `testBehaviour` tests for it will fail with "unknown command" or "unknown flag". +The ts-legacy CLI proxies a fixed, small set of commands to a Go binary (`SUPABASE_GO_BINARY` → bundled package binary → system `supabase`) — as of CLI-1970, `apps/cli-go/` contains only that residual proxied subset, nothing else, and it is slated for cleanup and removal (the surface only shrinks; never add tests that grow it). If your system `supabase` binary predates a flag or subcommand change on one of these, `testBehaviour` tests for it will fail with "unknown command" or "unknown flag". Build the Go CLI from source and point `SUPABASE_GO_BINARY` at it: diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index d9fe136e1d..4b32693b00 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -10,16 +10,21 @@ There are three source trees under `src/`: ``` src/ -├── next/ # New CLI experience (v3 / alpha channel) — do not modify when porting legacy commands -├── legacy/ # Strict 1:1 TypeScript port of the Go CLI (stable channel) +├── legacy/ # The stable Supabase CLI — the authoritative implementation +├── next/ # Experimental v3 shell — frozen; moving to its own branch, will leave this tree └── shared/ # Cross-cutting primitives used by both shells ``` +The names are historical: `legacy/` started as the TypeScript port of the old Go CLI and is now the +main, stable version of the CLI. `next/` is the experimental v3 experience; its development is +moving to a dedicated branch, and the folder will be removed from this tree. **Do not add features +to `next/`** — new work lands in `legacy/` (or `shared/`). + ### Isolation rules - `next/` and `legacy/` **cannot import each other**. Command trees are fully isolated. - Both shells import freely from `shared/`. -- **All exported tokens from `legacy/` must be prefixed with `Legacy` or `legacy`** (no exceptions — see naming section below). This prevents IDE auto-complete from suggesting legacy-only exports when working in `next/` and removes ambiguity at import sites. +- **All exported tokens from `legacy/` must be prefixed with `Legacy` or `legacy`** (no exceptions — see naming section below). This removes ambiguity at import sites and keeps the two in-tree shells from bleeding into each other while both exist. ### Entry points @@ -34,32 +39,27 @@ Both call `runCli(root)` from `shared/cli/run.ts`. --- -## Legacy Port Status and Go CLI Authority - -`src/legacy/` started as a from-scratch 1:1 port of the Go CLI (`apps/cli-go/`) and the port is now -complete: every legacy leaf command is natively ported except the delegation surface documented in -[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md). As of CLI-1970, `apps/cli-go/` -itself contains only that residual delegation surface — Go source for every other command was -deleted outright once nothing in the TypeScript CLI could reach it, directly or indirectly. For any -command whose Go source no longer exists in-tree, the authoritative parity reference is the last -commit with it intact: `7b469f5b3`. - -This changes what "Go CLI is authoritative" means day to day. `apps/cli-go/` is required reading -only when: - -- Working on one of the remaining Phase 0 wrapped commands — maintaining its command/flag - definition and proxy handler (these gate which invocations reach the Go binary and must still - match it exactly) or replacing the wrapper with a native implementation, or -- Changing something on an already-ported command's established parity surface: command/flag - names, stdout/stderr text, exit codes, all documented side effects (filesystem, database, - Docker/subprocess, API requests), or telemetry semantics (which events fire, when, and their - payload shape). - -For everything else in `src/legacy/` — bug fixes that don't touch that surface, internal refactors, -hoisting shared helpers, adding a documented TS-only flag/feature, tests, tooling — treat it like any -other TypeScript workspace. Go behavior is not the deciding standard, and there's no need to consult -`apps/cli-go/`. See [ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) -for the full rationale. +## Source of Truth + +The Go→TypeScript port is **complete**. `src/legacy/` is the source of truth for the Supabase CLI's +behavior. The old Go CLI (`apps/cli-go/`) is **not** a reference anymore: it survives only as the +residual delegation surface documented in +[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md), and everything Go-related is +slated for cleanup and removal. + +What this means in practice: + +- The compatibility standard for any change is the legacy shell's **own established behavior** — + its tests, its `SIDE_EFFECTS.md` files, and its shipped output — not a comparison against Go. +- Consult `apps/cli-go/` only when maintaining one of the still-proxied commands (their flag + definitions must keep matching the Go binary they forward to). +- **Do not write new comments, doc sections, or helper names framed as "Go parity" or "matches + Go".** Describe behavior in its own terms. When touching code that carries old Go-parity + framing, clean it up opportunistically. +- For history: Go source for ported commands was deleted in CLI-1970; the last commit with it + intact is `7b469f5b3` (`internal/start` was deleted separately in CLI-1966; its pin is + `a253ccba2`). [ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) + records the earlier transition policy; this section supersedes its day-to-day guidance. --- @@ -75,11 +75,6 @@ Use this for learning more about the library, rather than browsing the code in Use **`Effect.fn`** for top-level exported command handlers — tracing is desired. In the legacy shell, prefix the trace name with `legacy.` to distinguish legacy spans from `next/` spans in traces: ```ts -// next/ handler -export const create = Effect.fn("branches.create")(function* (flags: CreateFlags) { - // ... -}); - // legacy/ handler — note the legacy. prefix in the trace name export const legacyCreate = Effect.fn("legacy.branches.create")(function* ( flags: LegacyCreateFlags, @@ -102,7 +97,7 @@ Do not use `as` casts to paper over Effect or CLI typing issues. Fix the type re ## Shared Code -Always check `src/shared/` before writing new infrastructure. Do not duplicate what already exists there or in `next/`. +Always check `src/shared/` before writing new infrastructure. Do not duplicate what already exists there. | Path | What it provides | | -------------------------------------- | ------------------------------------------------------------------------------- | @@ -119,37 +114,32 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: -| Path | What it provides | -| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | -| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order | -| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | -| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | -| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | -| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | -| `legacy/shared/legacy-upgrade-notice.ts` | `legacyUpgradeNoticeHook` — post-success upgrade notice (Go `checkUpgrade` parity: GitHub latest-release fetch, 10h `supabase/.temp/cli-latest` cache, `SUPABASE_NO_UPDATE_NOTIFIER` opt-out) | +| Path | What it provides | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger (`log.LstdFlags`-style timestamp format) | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — the CLI's established ASCII table format | +| `legacy/shared/legacy-upgrade-notice.ts` | `legacyUpgradeNoticeHook` — post-success upgrade notice (GitHub latest-release fetch, 10h `supabase/.temp/cli-latest` cache, `SUPABASE_NO_UPDATE_NOTIFIER` opt-out) | --- -## Phase 0: Go Binary Wrapper +## The Go Delegation Surface -This phase is now the exception, not the default: only the commands in the -[delegation surface table](./docs/go-cli-porting-status.md#the-delegation-surface) -still need it. A Go CLI command that exists in `apps/cli-go/` but has no TS surface yet is rare at -this point in the port; when one does show up, the first step is to **wrap** it: define the command -in the TS command tree and proxy all invocations to the bundled Go binary via subprocess. Wrapping -only works for a command the Go CLI already implements — there is nothing to proxy to otherwise. A -genuinely TS-only addition with no Go equivalent (for example a TS-only flag on an already-ported -command, per the "Flag divergences from the Go reference" list in -[`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md)) is implemented natively and does -not go through Phase 0 at all. +A small, fixed set of commands still proxies to a bundled Go binary — the full list lives in +[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md). This surface only shrinks: +**never add a new command or flag path that delegates to the Go binary.** The direction of travel +is removing these remnants, not extending them. -### Proxy handler pattern +While a proxied command exists, its TS command/flag definition gates which invocations reach the +Go binary and must keep matching that binary exactly — this is the one remaining case where +`apps/cli-go/` is authoritative. -A proxy handler passes argv through to the Go binary, forwarding stdin/stdout/stderr and propagating the exit code. Use the shared `LegacyGoProxy` service: +A proxy handler passes argv through to the Go binary, forwarding stdin/stdout/stderr and propagating the exit code, via the shared `LegacyGoProxy` service: ```ts -// src/legacy/commands/orgs/list/list.handler.ts (Phase 0 proxy) export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* ( _flags: LegacyOrgsListFlags, ) { @@ -158,24 +148,21 @@ export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* ( }); ``` -### When wrapping a command - -For each command added to the Phase 0 wrapper, complete all three steps: - -1. **Reconstruct the command definition** — flags, subcommands, and argument types must exactly match the Go CLI (use `apps/cli-go/` as the reference). -2. **Write a proxy handler** — forward invocations to the Go binary via `LegacyGoProxy`. -3. **Update `docs/go-cli-porting-status.md`** — mark the command as `wrapped`. +Shrinking the surface means replacing a wrapper with a **native TS implementation** — it does not +license removing the user-facing command. Several wrapped commands are retained indefinitely +precisely because dropping them was ruled a breaking change (CLI-1964; see the "Why it stays" +column in the delegation table). Deleting a public command path is a product decision, never a +cleanup task. -### When porting a command (Phase 1+) +When replacing a wrapper natively: -When replacing a proxy handler with a native TS implementation: - -1. Implement the business logic in `.handler.ts` using Effect services (see Legacy Port sections below). -2. Update `docs/go-cli-porting-status.md` — mark the command as `ported`. +1. Implement the business logic in `.handler.ts` using Effect services (see the sections below). +2. Reproduce the old behavior (output, side effects, telemetry) from the **current in-tree Go source** in `apps/cli-go/` — that is the shipped implementation, and it can differ from the pre-trim snapshot (e.g. the `db remote --password` precedence entry in `docs/go-cli-divergences.md`). The pinned commits (`7b469f5b3`, `a253ccba2`) are only for commands whose Go source was already deleted. +3. Update `docs/go-cli-porting-status.md` — the delegation surface table must stay accurate. --- -## Legacy Port: File Structure and Naming +## File Structure and Naming ### Directory layout @@ -184,12 +171,12 @@ One directory per top-level command under `src/legacy/commands/`: ``` src/legacy/commands// .command.ts # Effect CLI Command definition, flag wiring, layer provision - .handler.ts # Phase 0: proxy handler. Phase 1+: native Effect implementation - .errors.ts # Domain error types (Data.TaggedError) — add when porting + .handler.ts # native Effect implementation (or residual Go proxy) + .errors.ts # Domain error types (Data.TaggedError) SIDE_EFFECTS.md # Required for every legacy command — see section below ``` -When a command grows beyond a single handler file, follow the optional helper-file shape that emerged from the backups port: +When a command grows beyond a single handler file, follow the optional helper-file shape: ``` src/legacy/commands// @@ -198,12 +185,12 @@ src/legacy/commands// .errors.ts # Data.TaggedError types .layers.ts # runtime layer composition for the command family .format.ts # text formatters (timestamps, regions, booleans) - .encoders.ts # Go-compatible JSON / YAML / TOML / env encoders - .go-payload.ts # Go struct specs mirroring types.gen.go — drive `-o yaml|toml` key casing (CLI-1975) + .encoders.ts # machine-format encoders (JSON / YAML / TOML / env) + .go-payload.ts # struct specs that drive `-o yaml|toml` key casing (CLI-1975) SIDE_EFFECTS.md ``` -The `.format.ts` and `.encoders.ts` files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes Go-parity rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null). +The `.format.ts` and `.encoders.ts` files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes encoding rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null). The `*.go-payload.ts` struct specs are now the canonical definition of `-o yaml|toml` key casing — they are no longer re-synced from any Go source. Commands with subcommands use nested directories: @@ -234,7 +221,7 @@ export const legacyRoot = Command.make("supabase").pipe( ### Mandatory `Legacy`/`legacy` prefix on all exports -Every exported token from a `legacy/` file must carry the `Legacy` (PascalCase) or `legacy` (camelCase/kebab) prefix — no exceptions, even for symbols that are only used within `legacy/`. This makes the constraint unconditional and prevents auto-complete pollution in `next/`: +Every exported token from a `legacy/` file must carry the `Legacy` (PascalCase) or `legacy` (camelCase/kebab) prefix — no exceptions, even for symbols that are only used within `legacy/`: | Export kind | Convention | | ------------------------------ | ----------------------------------------------------------- | @@ -248,150 +235,135 @@ Every exported token from a `legacy/` file must carry the `Legacy` (PascalCase) Do **not** export a bare `create` or `branchesCommand` from a `legacy/` file. -### Reusing `next/` implementations - -Many Management API commands in `next/commands/` have already been implemented. The handler logic is Effect-based and shell-agnostic. **Check `next/commands/` before writing a handler from scratch.** You can often copy a handler file verbatim and: - -1. Rename the exported function (add `legacy` prefix) -2. Adjust the trace name to `legacy..` -3. Fix import paths (`../../shared/` → `../../../shared/`, etc.) - --- -## Legacy Port: Hoist Before You Duplicate +## Hoist Before You Duplicate -Before writing handler code for a new port, scan the already-ported commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy. +Before writing handler code for a new command, scan the existing commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy. Decision rule: - **Used by one command only** → keep it in the command's own directory (e.g. `backups/backups.errors.ts`). - **Used by ≥2 commands in the same command family** → keep it in the family root (e.g. `backups/backups.encoders.ts` is shared by `list` and `restore`). -- **Used by ≥2 commands across families** → hoist to `src/legacy/shared/` (create the directory if it doesn't exist) and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version. +- **Used by ≥2 commands across families** → hoist to `src/legacy/shared/` and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version. -Concrete examples worth watching for as more commands land: +Concrete examples worth watching for: -- HTTP-error → tagged-error mapping (`backups.errors.ts:mapLegacyBackupHttpError`) — almost every Management API command will need this shape. -- Go-compatible JSON / YAML / TOML / env encoders (`backups.encoders.ts`) — the flag `--output {json,yaml,toml,env}` is supported by many Go subcommands. -- Glamour-table rendering helpers and column padding — currently in `legacy/output/legacy-glamour-table.ts`, already correctly hoisted. -- Timestamp / region / boolean formatters (`backups.format.ts`) — likely shared the moment a second command renders a backup/project/region field. +- HTTP-error → tagged-error mapping (`backups.errors.ts:mapLegacyBackupHttpError`) — almost every Management API command needs this shape. +- Machine-format encoders (`backups.encoders.ts`) — the `--output {json,yaml,toml,env}` flag is supported by many subcommands. +- Glamour-table rendering helpers and column padding — in `legacy/output/legacy-glamour-table.ts`, already correctly hoisted. +- Timestamp / region / boolean formatters (`backups.format.ts`) — shared the moment a second command renders a backup/project/region field. -This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. +This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor"). -### `Config.Validate` parity has one home +### Config validation has one home -Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. +Config validation is implemented exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. --- -## Legacy Port: Go CLI Output Parity +## Behavioral Stability Contract -The legacy shell is a **strict 1:1 port** — not a redesign. The compatibility contract covers: +The legacy shell is the stable CLI millions of scripts and CI pipelines depend on. Its established +surface is a compatibility contract: -- Same command paths and flag names -- Same stdout/stderr text, including spacing, casing, and newlines -- Same filesystem side effects (files read and written) -- Same API routes and request shapes -- Same exit codes +- Command paths and flag names +- stdout/stderr text, including spacing, casing, and newlines +- Filesystem side effects (files read and written) +- API routes and request shapes +- Exit codes +- Telemetry semantics (which events fire, when, and their payload shape) -When in doubt about expected output or behavior, run the equivalent command against the Go CLI reference at `apps/cli-go/` and match it exactly. +The standard for "established behavior" is the shell's own tests, each command's +`SIDE_EFFECTS.md`, and what current releases actually emit. Do not change this surface casually; +when a change is intentional, update the tests and `SIDE_EFFECTS.md` in the same change. -This contract governs behavior a command has already established — it does not mean every change in -`src/legacy/` requires consulting Go. It applies when working on one of the remaining Phase 0 -wrapped commands (its command/flag definition or its native replacement), or changing an -already-ported command in a way that could affect the surface above — which, per each command's -`SIDE_EFFECTS.md`, also includes database mutations and Docker/subprocess behavior, and per the -Telemetry Parity section below also includes which events fire and when, not just payload shape. It -does not apply to internal refactors, TS-only additions, or bug fixes that leave that surface -unchanged — see [Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority). +This contract does not constrain internal refactors, new flags/features, or bug fixes that leave +the established surface unchanged — treat those like any other TypeScript workspace. --- -## Legacy Port: Go Parity Checklist +## Legacy Shell Invariants -When porting a Management-API-style command, verify each item before marking the command as `ported`: +Verify each applicable item when adding or reworking a command: -1. **Telemetry + linked-project writes run on every invocation** — Go uses `PersistentPostRun` (see `apps/cli-go/cmd/root.go:176`). Wrap the handler body in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))` so both files are written on success **and** failure. See `backups/list/list.handler.ts:74-114` as the canonical pattern. +1. **Telemetry + linked-project writes run on every invocation** — wrap the handler body in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))` so both files are written on success **and** failure. See `backups/list/list.handler.ts:74-114` as the canonical pattern. -2. **Errors go to stderr in text mode, byte-matching Go's template** — `Output.fail` now writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when `--debug` is unset. Don't reintroduce clack's `■ … │` frame. Reference: commits `ee041834`, `cf4f574b`. +2. **Errors go to stderr in text mode** — `Output.fail` writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when `--debug` is unset. Don't reintroduce clack's `■ … │` frame. Reference: commits `ee041834`, `cf4f574b`. -3. **`--debug` logs every HTTP request on stderr** — Format `"HTTP YYYY/MM/DD HH:MM:SS : \n"` (Go's `log.LstdFlags|log.Lmsgprefix`). Provided automatically by `legacyHttpClientLayer`; ensure that layer (not the raw `HttpClient.layer`) is what every legacy command's runtime composes. Reference: commit `39cfec20`. +3. **`--debug` logs every HTTP request on stderr** — format `"HTTP YYYY/MM/DD HH:MM:SS : \n"`. Provided automatically by `legacyHttpClientLayer`; ensure that layer (not the raw `HttpClient.layer`) is what every legacy command's runtime composes. Reference: commit `39cfec20`. 4. **`SUPABASE_PROFILE` is dual-mode** — accept either a built-in name (`supabase`, `supabase-staging`, `supabase-local`) **or** a filesystem path to a YAML file with `api_url:` / `gotrue_url:` / `db_url:` keys. cli-e2e harness relies on the file-path mode. Reference: commit `288c2937`. 5. **`Layer.provide` does not share to siblings inside `Layer.mergeAll`** — if two sibling layers each require `LegacyCliConfig`, provide it to both explicitly. Smoke-test the bundled binary (`bun run build && ./dist/supabase-legacy …`) when changing production layer wiring; in-process tests don't always catch the missing-service panic. Reference: commit `a816b12e`, `backups.layers.ts:32-46`. -6. **Both `--output` (Go) and `--output-format` (TS) must be honored** — Go's `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on `goOutputFlag` first, then fall through to TS `--output-format` text/json/stream-json. - -7. **PostHog telemetry payload matches Go 1:1** — see the next section. +6. **Both `--output` (legacy machine formats) and `--output-format` must be honored** — `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on the `--output` flag first, then fall through to `--output-format` text/json/stream-json. -8. **Go API type regen re-syncs `*.go-payload.ts` specs** — when `apps/cli-go/pkg/api/types.gen.go` regenerates, re-audit every `*.go-payload.ts`/inline `LegacyGoType` struct spec that mirrors it (field order, JSON/Go name pairs); nothing checks this mechanically today (CLI-1975, review kanadgupta). +7. **Telemetry follows the established catalog and payload shapes** — see the next section. --- -## Legacy Port: Telemetry Parity +## Telemetry -The legacy shell sends the same PostHog events to the same product analytics pipeline as the Go CLI. Drift is silent (no test will catch it) and breaks dashboards. The rules: +The legacy shell sends PostHog events to the product analytics pipeline. Drift is silent (no test will catch it) and breaks dashboards. The rules: -- **The canonical catalog is `shared/telemetry/event-catalog.ts`** — a 1:1 mirror of `apps/cli-go/internal/telemetry/events.go`. Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. When the Go catalog changes, update the TS catalog in the same PR. -- **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits Go-shape properties: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. -- **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go. -- **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (historically found by grepping `apps/cli-go/cmd/*.go`). As of CLI-1970, only the retained commands' `cmd/*.go` files still exist in-tree (`db.go`, `functions.go`, `gen.go`, `root*.go`); the flags below came from `sso.go`, `branches.go`, `link.go`, and `projects.go`, all deleted once their commands went fully native — re-grep those at commit `7b469f5b3` if you need to re-derive this list. Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). -- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. -- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. This gives two flag families their real value automatically, via the existing boolean-is-safe rule and a new choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: +- **The canonical catalog is `shared/telemetry/event-catalog.ts`.** Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. The TS catalog is the source of truth for event names and property keys. +- **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits the established property shape: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. +- **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""`. +- **Use `safeFlags: ["flag-name"]`** to whitelist flags whose values are safe to log verbatim. The established list: `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). Extend it only for flags whose values carry no user data. +- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe — closed enums carry no user data — and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. +- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record. No per-command wiring needed. This gives two flag families their real value automatically, via the boolean-is-safe rule and the choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: - Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`. - Choice globals: `--output`, `--dns-resolver`, `--agent`. - Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted — matching Go, where `isEnumFlag` type-asserts `db diff`'s own non-enum local flag object instead of root's persistent `*utils.EnumFlag`. + Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted. - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. -- **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested` — note that most `internal//` packages were deleted in CLI-1970 once their commands went fully native, so this grep only finds something for the still-`wrapped` commands; check out commit `7b469f5b3` to grep an already-ported command's former Go source. The current Go custom events that legacy ports must reproduce when natively ported (already captured below, so this is only needed for a command not yet in this table): +- **Custom events are established behavior — do not drop, rename, or reshape them.** Beyond `cli_command_executed`, the legacy shell fires: - | Command | Event | Identity / groups | Go source | - | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | - | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` (deleted in CLI-1970; last present at commit 7b469f5b3) | - | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` (deleted in CLI-1970; last present at commit 7b469f5b3) | - | `start` | `cli_stack_started` | none — fired after stack health check passes | formerly `internal/start/start.go:1245` (deleted as unreachable in CLI-1966; last present at commit a253ccba2) | - | `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (`SuggestUpgradeOnError` is envelope-first; hostnames + vanity get are envelope-only) | call-sites under `internal/{sso,branches,hostnames,vanity_subdomains}/` (deleted in CLI-1970; last present at commit 7b469f5b3) | + | Command | Event | Identity / groups | + | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | + | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | + | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | + | `start` | `cli_stack_started` | none — fired after stack health check passes | + | `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (envelope-first; hostnames + vanity get envelope-only) | - Reference pattern for login: `next/commands/login/login.handler.ts:38-62`. + See `legacy/commands/login/` (handler + `SIDE_EFFECTS.md`) for the reference pattern. - TS-only extension to the `link` row (CLI-2167, no Go counterpart): when `link` resolves a - branch name/UUID (`[ref-or-branch]` positional or `--project-ref`) to its project ref, the - legacy shell additionally fires `cli_project_linked` with `linked_via: "branch"` and - `parent_project_ref` set, plus a `project` group association (no `groupIdentify` call, since - no org/name metadata exists for a branch). + `link` extension (CLI-2167): when `link` resolves a branch name/UUID (`[ref-or-branch]` + positional or `--project-ref`) to its project ref, it additionally fires `cli_project_linked` + with `linked_via: "branch"` and `parent_project_ref` set, plus a `project` group association (no + `groupIdentify` call, since no org/name metadata exists for a branch). -- **Tracing layer is local-only observability**, not PostHog. Span names (`legacy..`) and the NDJSON exporter never leave the user's machine. No parity implication. +- **Tracing layer is local-only observability**, not PostHog. Span names (`legacy..`) and the NDJSON exporter never leave the user's machine. No compatibility implication. --- -## Legacy Port: File Location Compatibility +## File Location Compatibility -The legacy shell bridges two worlds: it must behave exactly like the Go CLI for existing users, and it must lay the groundwork for a seamless upgrade to the next shell. +The CLI's on-disk state locations are part of the compatibility contract: existing scripts, +dotfiles, and tooling depend on the exact paths the stable CLI has always used (`~/.supabase/…`, +`/supabase/.temp/…`, native keyring entries). Do not move or rename these files. -**Dual write requirement:** Where a legacy command writes state to disk, it must write to **both**: - -1. **The Go CLI paths** — the exact file locations the Go CLI already uses, so existing scripts, dotfiles, and tooling that depend on those paths continue to work. -2. **The `next/` paths** — the file locations that `next/` services and layers expect to read, so a user who upgrades to the next experience finds their state already in place. - -When these two sets of paths are the same (they often are via shared services), no extra work is needed. When they differ, the legacy handler must write to both. - -**Corollary:** When a `next/` service or layer changes where or how it reads or writes a file, the author must verify that the corresponding legacy command still produces files at the updated location and update it if necessary before merging. This check is required even when file I/O goes through a shared service — confirm the shared service covers both paths. +While `next/` remains in this tree, a legacy command that writes state must also write to the +locations `next/` services expect to read (when they differ — they are often the same via shared +services), so state stays portable. This dual-write obligation leaves with `next/` when it moves +to its own branch. --- -## Legacy Port: Side-effect Documentation +## Side-effect Documentation `SIDE_EFFECTS.md` is a **legacy-only artifact**. Do not create these files in `next/`. -Every legacy command port must include a `SIDE_EFFECTS.md` in its command directory covering: +Every legacy command must have a `SIDE_EFFECTS.md` in its command directory covering: - **Files read and written** — exact paths (with `~/` or CWD-relative notation), format, when - **API routes called** — method, path, request body shape, response shape - **Environment variables consumed** - **Exit codes** — including error conditions -Use the template at `src/legacy/SIDE_EFFECTS_TEMPLATE.md`. This document is the compatibility checklist for the port and the primary input to the E2E test suite. +Use the template at `src/legacy/SIDE_EFFECTS_TEMPLATE.md`. This document is the command's compatibility checklist and the primary input to the E2E test suite. Keep it accurate when changing a command's behavior. --- @@ -466,15 +438,15 @@ yield * creating.succeed("Branch created"); ### Invariant: `-o json|yaml|toml|env` must suppress the spinner (CLI-1546) -The Go-compat `-o`/`--output` flag (`LegacyOutputFlag`, values `env|pretty|json|toml|yaml`) is **independent** of `--output-format`. It does not change `output.format`, so a command run with `-o json` (and no `--output-format`) keeps `output.format === "text"` and the spinner gate `output.format === "text"` stays `true`. If the plain `textOutputLayer` is active, clack writes spinner ANSI (e.g. the hide-cursor `\x1b[?25l`) to **stdout** and corrupts the machine payload the handler emits via `output.raw` — exactly the CLI-1546 regression (`branches list -o json` → broken `JSON.parse`). +The legacy machine-format `-o`/`--output` flag (`LegacyOutputFlag`, values `env|pretty|json|toml|yaml`) is **independent** of `--output-format`. It does not change `output.format`, so a command run with `-o json` (and no `--output-format`) keeps `output.format === "text"` and the spinner gate `output.format === "text"` stays `true`. If the plain `textOutputLayer` is active, clack writes spinner ANSI (e.g. the hide-cursor `\x1b[?25l`) to **stdout** and corrupts the machine payload the handler emits via `output.raw` — exactly the CLI-1546 regression (`branches list -o json` → broken `JSON.parse`). -`legacy/cli/root.ts` therefore selects **`legacyQuietProgressTextOutputLayer`** (in `legacy/output/`) for any Go machine format (`json|yaml|toml|env`). It is a legacy-only wrapper over the shared `textOutputLayer` that no-ops only `task` and `progress`; everything else — `format: "text"`, `raw`, logs, and error rendering (red text on **stderr**) — delegates unchanged, so Go output parity is preserved exactly. +`legacy/cli/root.ts` therefore selects **`legacyQuietProgressTextOutputLayer`** (in `legacy/output/`) for any machine format (`json|yaml|toml|env`). It is a legacy-only wrapper over the shared `textOutputLayer` that no-ops only `task` and `progress`; everything else — `format: "text"`, `raw`, logs, and error rendering (red text on **stderr**) — delegates unchanged, so established output stays byte-identical. Rules: - **stdout is payload-only whenever a machine format is requested** (`-o json|yaml|toml|env` or `--output-format json|stream-json`). All progress/diagnostic output goes to stderr. - **Do not** fix spinner-on-stdout by routing the shared spinner to stderr or otherwise editing `shared/output/output.layer.ts` — that changes `next/` text rendering. Keep the fix legacy-scoped. -- A handler reaching this path still emits its machine payload through the Go encoder (`output.raw(encodeGoJson(...))` etc.), checked **before** the `output.format` branch, so output stays byte-identical to before — minus the spinner. +- A handler reaching this path still emits its machine payload through the established encoders (`output.raw(encodeGoJson(...))` etc.), checked **before** the `output.format` branch, so output stays byte-identical — minus the spinner. --- @@ -527,24 +499,21 @@ Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). - **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. -- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. +- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. - **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. - Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). --- -## Go CLI Parity Tracking - -The legacy port is complete, so this is no longer a per-command tracking exercise: +## Compatibility Docs -- When you add a TS-only flag, positional argument, or behavior on an already-ported **legacy - shell** command (no Go equivalent), record it in - [`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md) in the same change. Same for a - TS-native command with no direct Go equivalent (for example `dev`) — record it in that file's - TS-only section. -- Only touch [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md) if the delegation - surface itself changes — a command is added to or removed from the fixed set `LegacyGoProxy` - still forwards to the Go binary. +- [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md) documents the residual Go + delegation surface. Update it only when that surface shrinks (a wrapper is replaced natively or + removed). +- [`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md) is a **frozen historical record** of + where the TS port intentionally diverged from the old Go CLI. Do not add new entries — new + flags and features are simply new CLI behavior, documented through help text, tests, and + `SIDE_EFFECTS.md` like anything else. --- @@ -571,18 +540,10 @@ bun run --parallel "*:check" ### `apps/cli-go/` -The [old Supabase CLI](https://github.com/supabase/cli) written in Go. As of CLI-1970, this tree -contains only the residual proxied subset — the still-`wrapped` commands, plus the single -Go-delegated flag paths on `db diff`, `db pull`, and `functions download`. Every other command's -source was deleted outright once nothing in the TypeScript CLI could reach it, directly or -indirectly. Use it as the authoritative source, matched exactly, when working on one of the -remaining wrapped commands (maintaining its command/flag definition, or replacing the wrapper with -native TS), or when changing an already-ported legacy command's established output/flags/behavior/ -side-effects. It is not required reading for other legacy-shell work — see -[Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority) and -[ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md). For any -command whose Go source no longer exists in-tree, the last commit with it intact (`7b469f5b3`) is -the authoritative reference instead. Exception: `internal/start` (Go's `supabase start`) was -deleted outright as unreachable once ported (CLI-1966) — for that command specifically, the last -commit with the source intact (`a253ccba25c21356ccd33044c4474aecb77d1ae4`) is the authoritative -reference instead. +The remnants of the [old Go Supabase CLI](https://github.com/supabase/cli). It contains **only** +the residual delegation surface (the still-proxied commands listed in +[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md)) and is slated for cleanup and +eventual removal. It is not a reference for anything else — consult it only when maintaining a +proxied command's flag definition, which must keep matching the Go binary it forwards to. For the +history of any deleted Go command source, use the pinned commits `7b469f5b3` (general) and +`a253ccba2` (`internal/start`). diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index dae26a95da..380d8775e8 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -22,7 +22,7 @@ The legacy shell was built as a gradual TypeScript port of the Go CLI, moving ea - **Phase 0** — The command is defined in the TS CLI tree but proxied to the Go binary at runtime via `LegacyGoProxy`. - **Phase 1+** — The command is implemented natively in TypeScript. -That port is complete (CLI-1970). `supabase-go` is no longer a shrinking, transitional Phase 0 artifact — it is a permanent residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), `db pull` (for `--experimental`), the Go-deprecated `db branch`/`db remote` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. The TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how. +That port is complete (CLI-1970). `supabase-go` is the residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), `db pull` (for `--experimental`), the Go-deprecated `db branch`/`db remote` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. Two lifecycles apply here and must not be conflated: the **public commands** in that surface are retained (dropping them was ruled a breaking change — CLI-1964), while their **Go implementations** are slated for eventual native TS replacement, after which `supabase-go` stops shipping. Until the proxied surface is empty, the TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how. ## Package Layout @@ -103,7 +103,7 @@ This: - `db pull` — kept for `--experimental`, which needs the multigres Postgres DDL parser for structured dumps (CLI-1957) - `db branch create`, `db branch delete`, `db branch list`, `db branch switch` - `db remote changes`, `db remote commit` -- `gen keys` — kept indefinitely; its planned removal (CLI-1964) was cancelled +- `gen keys` — the public command is kept (its planned removal, CLI-1964, was cancelled as a breaking change); the Go implementation stays only until a native TS replacement lands - `functions download` — kept for the hidden `--legacy-bundle` path (CLI-1963) That list is exhaustive: `LegacyGoProxy` is the only code path in the TypeScript CLI that spawns `supabase-go`. The one historical exception — the hidden pg-delta seam (`db schema declarative __catalog` + `db start`), spawned directly by the native `db schema declarative generate|sync` commands to provision shadow databases and export pg-delta catalogs — was ported to native TypeScript as part of CLI-1970 (`legacy-pgdelta.seam.layer.ts` now composes `legacySetupShadowDatabase`/`legacyExportCatalogPgDelta`/`legacyStartLocalDatabase` in-process), and those two Go commands were deleted with it. diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 228fc2af93..085bf9cab1 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -1,10 +1,12 @@ # Go CLI Divergences -Ledger of deliberate TypeScript divergences from the old Go CLI (pre-`7b469f5b3`) on the legacy -shell: TS-only commands, flags, and behavior with no Go counterpart. When you add a TS-only flag or -a deliberate behavioral change to an already-ported legacy command, add an entry here in the same -change. This document exists to answer support and migration questions about why the TS CLI does -something the old Go CLI didn't — it is not a compatibility promise. +**Frozen historical ledger** of deliberate TypeScript divergences from the old Go CLI +(pre-`7b469f5b3`) on the legacy shell: TS-only commands, flags, and behavior with no Go +counterpart. The TypeScript CLI is now the source of truth, so this ledger no longer accumulates +entries — new flags, commands, and behavioral changes are simply new CLI behavior, documented +through help text, tests, and each command's `SIDE_EFFECTS.md`. This document exists to answer +support and migration questions about why the TS CLI does something the old Go CLI didn't — it is +not a compatibility promise. ## TS-only Commands diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 8b06e80099..a5d617bf72 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -12,9 +12,10 @@ with it intact: `7b469f5b3` (CLI-1966's `internal/start` pin remains its own, se `a253ccba2`). See [`binary-distribution.md`](./binary-distribution.md) for how these two binaries are packaged, -resolved at runtime, and sized, and -[ADR 0016](../../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) for the -policy on when `apps/cli-go/` is actually authoritative for day-to-day legacy-shell work. +resolved at runtime, and sized. The TypeScript CLI is the source of truth for all CLI behavior; +`apps/cli-go/` is authoritative only for the proxied commands below, and the whole surface is +slated for cleanup and removal ([ADR 0016](../../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) +records the earlier transition policy). ## The delegation surface From 3e2df928b23917c2f3c3facb88106f2809d64c42 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 20 Aug 2026 19:19:53 +0000 Subject: [PATCH 14/63] fix(cli): clamp edge-runtime nofile ulimit to the host hard limit (#6284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The Edge Runtime container was always created with `--ulimit nofile=65536:65536` — a raise inherited from the Go CLI so many concurrent Deno isolates can run (supabase/cli#5151). Sandboxed hosts cap the hard nofile limit lower (e.g. 20,000 in the Claude Code sandbox), their docker daemon shares that cap, and requesting more than the daemon can grant fails the container start outright. This adds `edgeRuntimeNofileUlimit(platformOs)` in `@supabase/stack` and uses it at both docker call sites (stack service defs for the next shell, and `shared/functions/serve.ts` for legacy `functions serve`/`start`): - On Linux, the requested value is clamped to the process's own hard limit, read via `process.report.getReport().userLimits.open_files` (the standard runtime API, implemented by both Bun and Node — verified under Bun in a Linux container with a constrained `--ulimit`). - The clamp only ever lowers the request, so the worst case on an exotic setup (client more constrained than a remote/rootful daemon) is a smaller fd budget, never a failed start. - When the clamp lowers the request, the CLI emits a warning naming the reduced limit ("Edge Runtime file descriptor limit lowered to N: …") so the smaller fd budget is visible instead of silent. It surfaces through `Output.warn` in `startEdgeRuntimeContainer`, covering both `functions serve` and legacy `start`; the stack `ServiceDef` builder is pure with no output channel and stays silent. - Off Linux the full 65536 raise is kept, since the daemon runs in a VM with its own limits. The Go-parity divergence is documented in `apps/cli/docs/go-cli-divergences.md`. Reviewer notes: the helper stays a plain sync leaf (no failure modes/retries/resources) per the repo's Effect-native carve-out — both call sites are sync `ServiceDef` builders — and takes `platformOs` as input to match how the stack threads `platform.os` instead of reading `process.platform` ambiently. It now returns `{ arg, limit, clampWarning? }` with the host hard limit injectable (defaulting to the real `process.report` probe), so the clamp decision and warning text carry deterministic unit coverage alongside the pure seams (`hardNofileLimitFromReport`, `clampNofileLimit`), including the 20,000-cap case. ## Linked issue Closes CLI-2220 (Linear) - [x] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). ## Checklist - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). - [x] Tests added or updated for the change. - [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- apps/cli/docs/go-cli-divergences.md | 14 ++++ .../edge-runtime.service.integration.test.ts | 29 +++++--- .../start/services/edge-runtime.service.ts | 2 +- apps/cli/src/shared/functions/serve.ts | 13 +++- packages/stack/src/effect.ts | 2 + packages/stack/src/services/edge-runtime.ts | 3 +- packages/stack/src/services/nofile-limit.ts | 59 +++++++++++++++ .../src/services/nofile-limit.unit.test.ts | 74 +++++++++++++++++++ .../stack/src/services/services.unit.test.ts | 3 +- 9 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 packages/stack/src/services/nofile-limit.ts create mode 100644 packages/stack/src/services/nofile-limit.unit.test.ts diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 085bf9cab1..693e2e3328 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -232,3 +232,17 @@ These commands exist in the TS CLI today but have no direct top-level equivalent pull."). An in-sync database is a finding, not a failure to troubleshoot, so the debug hint sent users chasing a non-existent bug. Message text and exit code — the parts scripts depend on — are unchanged. +- Edge Runtime's Docker container `--ulimit nofile` value (`functions serve` and `start`): Go + hardcodes `nofile=65536:65536`, raised from the daemon default to accommodate FD usage from + many concurrent Deno isolates (supabase/cli#5151). TS clamps that value to the host's own hard + nofile limit on Linux (`@supabase/stack`'s `edgeRuntimeNofileUlimit`, via + `process.report`'s `userLimits`), so a constrained sandbox (hard cap below 65536) can still start the + container instead of failing outright (CLI-2220). The CLI process's own limit is used as a + proxy for the daemon's — exact in the sandboxes this targets, where both share the cap; a + Linux client more constrained than its daemon (remote `DOCKER_HOST`, mounted socket) just + gets a smaller fd budget, never a failed start. When the clamp lowers the request, the legacy + `functions serve`/`start` bring-up warns with the reduced limit. The `@supabase/stack` service + builder (next-shell `stack start`) applies the same clamp silently: its defs are built without + an output channel, and in managed mode inside the daemon process, so a user-visible warning + there needs a diagnostics channel on `BuildResult` first; the applied value stays visible via + `docker inspect`. diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts index 79ae42c489..913582d235 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { edgeRuntimeNofileUlimit } from "@supabase/stack/effect"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { beforeEach } from "vitest"; @@ -189,20 +190,24 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); - it.effect("sets --ulimit nofile=65536:65536, matching Go's Ulimits container.Config", () => - Effect.gen(function* () { - const mock = mockDockerSpawner(); - const out = mockOutput(); + it.effect( + "sets --ulimit nofile, capped at Go's 65536 and clamped to the host hard limit (CLI-2220)", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); - yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), - ); + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); - const runCall = mock.runCall!; - const ulimitIndex = runCall.args.indexOf("--ulimit"); - expect(runCall.args[ulimitIndex + 1]).toBe("nofile=65536:65536"); - }), + const runCall = mock.runCall!; + const ulimitIndex = runCall.args.indexOf("--ulimit"); + expect(runCall.args[ulimitIndex + 1]).toBe(edgeRuntimeNofileUlimit("darwin").arg); + // Off Linux the raise is never clamped, so no clamp warning is emitted. + expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + }), ); it.effect("sets --workdir once an enabled function mounts the project root (#6035)", () => diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts index e283f0c822..2d85b4d64c 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -13,7 +13,7 @@ * - `docker-create-args.ts`'s own header explicitly excludes `WorkingDir` * and `Ulimits` from `LegacyStartContainerSpec` ("none of the 13 [other] * call sites... set them") — Edge Runtime needs BOTH (`--workdir`, - * `--ulimit nofile=65536:65536`), so "mapping cleanly" would mean + * `--ulimit nofile`, host-clamped), so "mapping cleanly" would mean * extending the shared spec for a single caller. * - Every other service's env travels as bare `-e KEY` flags whose values * come from the spawned `docker create` process's own environment diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 0db4d04c47..479889d723 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -10,7 +10,12 @@ import { type ResolvedProjectValue, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config"; -import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { + defaultJwtSecret, + defaultPublishableKey, + defaultSecretKey, + edgeRuntimeNofileUlimit, +} from "@supabase/stack/effect"; import { createHmac, createPrivateKey, @@ -1744,6 +1749,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }); const containerProjectRoot = toDockerPath(input.projectRoot); + const nofile = edgeRuntimeNofileUlimit(input.platform); + if (nofile.clampWarning !== undefined) { + yield* output.warn(nofile.clampWarning); + } const command = [ "create", "--name", @@ -1754,7 +1763,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo "edge_runtime", ...(hasBindUnder(binds, containerProjectRoot) ? ["--workdir", containerProjectRoot] : []), "--ulimit", - "nofile=65536:65536", + nofile.arg, "--label", `com.supabase.cli.project=${labels["com.supabase.cli.project"]}`, "--label", diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 56ca2752cf..79f2e1542c 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -37,6 +37,8 @@ export { generateJwt, } from "./JwtGenerator.ts"; +export { edgeRuntimeNofileUlimit } from "./services/nofile-limit.ts"; + export type { AllocatedPorts, ConfigPortKey, diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 168a14cd26..3938b7d5c0 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -6,6 +6,7 @@ import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; import { stackHealthBudgets } from "./health-budgets.ts"; +import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; interface EdgeRuntimeOptions { readonly runtimeRoot: string; @@ -94,7 +95,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se `${bootstrapDir}:${bootstrapMountDir}:ro`, ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), ], - args: ["--ulimit", "nofile=65536:65536"], + args: ["--ulimit", edgeRuntimeNofileUlimit(opts.platformOs).arg], env: { ...edgeRuntimeEnv(opts), FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, diff --git a/packages/stack/src/services/nofile-limit.ts b/packages/stack/src/services/nofile-limit.ts new file mode 100644 index 0000000000..2c2b8df9c6 --- /dev/null +++ b/packages/stack/src/services/nofile-limit.ts @@ -0,0 +1,59 @@ +// Raised from the daemon default so many concurrent Deno isolates can run +// (supabase/cli#5151). +const desiredNofile = 65536; + +// `userLimits.open_files.hard` from a `process.report.getReport()` diagnostic +// report — a number or "unlimited". Narrowed structurally because getReport() +// is typed as a bare `object`. +export const hardNofileLimitFromReport = (report: unknown): number | undefined => { + if (typeof report !== "object" || report === null || !("userLimits" in report)) return undefined; + const userLimits = report.userLimits; + if (typeof userLimits !== "object" || userLimits === null || !("open_files" in userLimits)) { + return undefined; + } + const openFiles = userLimits.open_files; + if (typeof openFiles !== "object" || openFiles === null || !("hard" in openFiles)) { + return undefined; + } + const hard = openFiles.hard; + return typeof hard === "number" && Number.isSafeInteger(hard) && hard > 0 ? hard : undefined; +}; + +const hostHardNofileLimit = (platformOs: string): number | undefined => + platformOs === "linux" ? hardNofileLimitFromReport(process.report?.getReport()) : undefined; + +// Never request more than the host's own hard cap: sandboxed hosts cap it +// below 65536, their docker daemon shares the cap, and exceeding it fails the +// container start (CLI-2220). The process's limit is a proxy for the daemon's +// — exact only when they share a kernel and limits, so only Linux is clamped +// (elsewhere the daemon runs in a VM), and only downward: a client more +// constrained than its daemon yields a smaller fd budget, never a failed start. +export const clampNofileLimit = (hardLimit: number | undefined): number => + hardLimit === undefined ? desiredNofile : Math.min(desiredNofile, hardLimit); + +interface EdgeRuntimeNofileUlimit { + /** The docker `--ulimit` value, `nofile=:`. */ + readonly arg: string; + readonly limit: number; + /** Present only when the host's hard cap forced the request below the 65536 raise. */ + readonly clampWarning?: string; +} + +// `hostHardLimit` defaults to the real host probe and exists as a parameter so +// callers with no host dependence (tests) can pin the clamp decision. +export const edgeRuntimeNofileUlimit = ( + platformOs: string, + hostHardLimit: number | undefined = hostHardNofileLimit(platformOs), +): EdgeRuntimeNofileUlimit => { + const limit = clampNofileLimit(hostHardLimit); + return { + arg: `nofile=${limit}:${limit}`, + limit, + ...(limit < desiredNofile && { + clampWarning: + `Edge Runtime file descriptor limit lowered to ${limit}: ` + + `the host's hard limit (ulimit -Hn) is below the default ${desiredNofile}. ` + + `Heavy Edge Function workloads may exhaust file descriptors.`, + }), + }; +}; diff --git a/packages/stack/src/services/nofile-limit.unit.test.ts b/packages/stack/src/services/nofile-limit.unit.test.ts new file mode 100644 index 0000000000..acda910c26 --- /dev/null +++ b/packages/stack/src/services/nofile-limit.unit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + clampNofileLimit, + edgeRuntimeNofileUlimit, + hardNofileLimitFromReport, +} from "./nofile-limit.ts"; + +const reportWithHard = (hard: number | string) => ({ + header: { reportVersion: 5 }, + userLimits: { + open_files: { soft: 1024, hard }, + }, +}); + +describe("hardNofileLimitFromReport", () => { + it("reads the hard limit from a diagnostic report", () => { + expect(hardNofileLimitFromReport(reportWithHard(1048576))).toBe(1048576); + expect(hardNofileLimitFromReport(reportWithHard(20000))).toBe(20000); + }); + + it("returns undefined when the hard limit is unlimited", () => { + expect(hardNofileLimitFromReport(reportWithHard("unlimited"))).toBeUndefined(); + }); + + it("returns undefined for missing or malformed report shapes", () => { + expect(hardNofileLimitFromReport(undefined)).toBeUndefined(); + expect(hardNofileLimitFromReport(null)).toBeUndefined(); + expect(hardNofileLimitFromReport({})).toBeUndefined(); + expect(hardNofileLimitFromReport({ userLimits: {} })).toBeUndefined(); + expect(hardNofileLimitFromReport({ userLimits: { open_files: {} } })).toBeUndefined(); + expect(hardNofileLimitFromReport(reportWithHard(-1))).toBeUndefined(); + }); +}); + +describe("clampNofileLimit", () => { + it("keeps the 65536 raise when the hard limit is unknown or higher", () => { + expect(clampNofileLimit(undefined)).toBe(65536); + expect(clampNofileLimit(1048576)).toBe(65536); + expect(clampNofileLimit(65536)).toBe(65536); + }); + + it("clamps down to a lower hard limit (CLI-2220's 20000-cap sandbox)", () => { + expect(clampNofileLimit(20000)).toBe(20000); + }); +}); + +describe("edgeRuntimeNofileUlimit", () => { + it("keeps the full 65536 raise off Linux, where the daemon runs in a VM", () => { + expect(edgeRuntimeNofileUlimit("darwin")).toEqual({ arg: "nofile=65536:65536", limit: 65536 }); + expect(edgeRuntimeNofileUlimit("win32")).toEqual({ arg: "nofile=65536:65536", limit: 65536 }); + }); + + it("produces a matched soft:hard arg within Go's 65536 raise on Linux", () => { + const { arg, limit, clampWarning } = edgeRuntimeNofileUlimit("linux"); + expect(arg).toBe(`nofile=${limit}:${limit}`); + expect(limit).toBeGreaterThan(0); + expect(limit).toBeLessThanOrEqual(65536); + // The warning exists exactly when this host's cap forced a reduction. + expect(clampWarning !== undefined).toBe(limit < 65536); + }); + + it("carries a warning when a lower host hard limit forces a clamp (CLI-2220's 20000-cap sandbox)", () => { + const clamped = edgeRuntimeNofileUlimit("linux", 20000); + expect(clamped.arg).toBe("nofile=20000:20000"); + expect(clamped.limit).toBe(20000); + expect(clamped.clampWarning).toContain("lowered to 20000"); + expect(clamped.clampWarning).toContain("65536"); + }); + + it("omits the warning when the host limit does not constrain the raise", () => { + expect(edgeRuntimeNofileUlimit("linux", 1048576).clampWarning).toBeUndefined(); + expect(edgeRuntimeNofileUlimit("linux", 65536).clampWarning).toBeUndefined(); + }); +}); diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 6259462de5..ca84c3b279 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; +import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; import { makePgmetaServiceDocker } from "./pgmeta.ts"; @@ -407,7 +408,7 @@ describe("makeEdgeRuntimeServiceDocker", () => { expect(def.args).toContain(`--policy=per_worker`); expect(def.args).toContain(`${bootstrapDir}:/workspace:ro`); expect(def.args).toContain("--ulimit"); - expect(def.args).toContain("nofile=65536:65536"); + expect(def.args).toContain(edgeRuntimeNofileUlimit("linux").arg); expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); expect(def.healthCheck?.probe).toEqual({ _tag: "Http", From 1e553f45ec92a7ea62b007e9a492bf1ea477bd9a Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:20:29 +0000 Subject: [PATCH 15/63] chore(api): sync Management API OpenAPI spec (#6280) This PR was automatically created to sync the generated `@supabase/api` package with the latest Management API OpenAPI document. Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. Co-authored-by: jgoux <1443499+jgoux@users.noreply.github.com> --- packages/api/src/generated/openapi.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 2130e09552..f6f70837da 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -6970,6 +6970,9 @@ } } }, + "400": { + "description": "Project must be active and healthy, or metrics are not available for this project" + }, "401": { "description": "Unauthorized" }, From bb3bb5c47eac1c8276236593afb1e5d66bb7692a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:12:36 +0000 Subject: [PATCH 16/63] fix(deps): bump the npm-major group with 7 updates (#6278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 7 updates: | Package | From | To | | --- | --- | --- | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.228` | `0.3.229` | | [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.48.1` | `5.48.2` | | [@effect/platform-bun](https://github.com/Effect-TS/effect/tree/HEAD/packages/platform-bun) | `4.0.0-beta.107` | `4.0.0-rc.108` | | [@effect/platform-node](https://github.com/Effect-TS/effect/tree/HEAD/packages/platform-node) | `4.0.0-beta.107` | `4.0.0-rc.108` | | [@effect/sql-pg](https://github.com/Effect-TS/effect/tree/HEAD/packages/sql-pg) | `4.0.0-beta.107` | `4.0.0-rc.108` | | [@effect/vitest](https://github.com/Effect-TS/effect/tree/HEAD/packages/vitest) | `4.0.0-beta.107` | `4.0.0-rc.108` | | [effect](https://github.com/Effect-TS/effect/tree/HEAD/packages/effect) | `4.0.0-beta.107` | `4.0.0-rc.108` | Updates `@anthropic-ai/claude-agent-sdk` from 0.3.228 to 0.3.229
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.229

What's changed

  • Added terminal_slash_commands to the system init message so Remote Control clients can hide terminal-oriented commands
  • Changed conversations whose messages alone exceed the API's 32 MB limit to end the turn with terminal_reason "api_error" instead of "image_error"; StopFailure error_details is "request_body_over_limit: …"

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.229
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.229
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.229
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.229
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.229

  • Added terminal_slash_commands to the system init message so Remote Control clients can hide terminal-oriented commands
  • Changed conversations whose messages alone exceed the API's 32 MB limit to end the turn with terminal_reason "api_error" instead of "image_error"; StopFailure error_details is "request_body_over_limit: …"
Commits

Updates `posthog-node` from 5.48.1 to 5.48.2
Release notes

Sourced from posthog-node's releases.

posthog-node@5.48.2

5.48.2

Patch Changes

  • #4506 a77115b Thanks @​marandaneto! - Log shutdown timeouts without rejecting, and correct the Node.js shutdown() return type to Promise<void>. (2026-08-12)
  • Updated dependencies [a77115b]:
    • @​posthog/core@​1.47.1
Changelog

Sourced from posthog-node's changelog.

5.48.2

Patch Changes

  • #4506 a77115b Thanks @​marandaneto! - Log shutdown timeouts without rejecting, and correct the Node.js shutdown() return type to Promise<void>. (2026-08-12)
  • Updated dependencies [a77115b]:
    • @​posthog/core@​1.47.1
Commits
  • 9c0632a chore: update versions and lockfile [version bump]
  • a77115b fix(node): resolve shutdown timeouts without rejecting (#4506)
  • 3c9cd11 chore: update versions and lockfile [version bump]
  • See full diff in compare view

Updates `@effect/platform-bun` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes

Sourced from @​effect/platform-bun's releases.

@​effect/platform-bun@​4.0.0-rc.108

Patch Changes

Commits

Updates `@effect/platform-node` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes

Sourced from @​effect/platform-node's releases.

@​effect/platform-node@​4.0.0-rc.108

Patch Changes

@​effect/platform-node-shared@​4.0.0-rc.108

Patch Changes

Commits

Updates `@effect/sql-pg` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes

Sourced from @​effect/sql-pg's releases.

@​effect/sql-pglite@​4.0.0-rc.108

Patch Changes

@​effect/sql-pg@​4.0.0-rc.108

Patch Changes

Commits

Updates `@effect/vitest` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes

Sourced from @​effect/vitest's releases.

@​effect/vitest@​4.0.0-rc.108

Patch Changes

Changelog

Sourced from @​effect/vitest's changelog.

4.0.0-rc.108

Patch Changes

Commits

Updates `effect` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes

Sourced from effect's releases.

effect@4.0.0-rc.108

Patch Changes

  • #6546 dfb173e Thanks @​xianjianlf2! - Handle BigInt values safely and consistently across JSON diagnostics and logger formats.

  • #7174 005e090 Thanks @​tim-smart! - Fix Queue.await failing with Cause.Done when registered before the queue ends.

  • #7180 c82c532 Thanks @​gcanti! - Prioritize redacted representations in formatters and normalize text logger levels to uppercase.

  • #7193 22b579f Thanks @​kitlangton! - Fix Deferred.await dying with a TypeError when a waiter is interrupted after the Deferred has been completed.

  • #7179 3e19539 Thanks @​tim-smart! - Fix DurableDeferred.raceAll so a completed deferred can wake an active workflow without changing success-biased race semantics

  • #7189 08a3c74 Thanks @​gcanti! - Fix HttpApi query decoding for array parameters with a single value.

  • #6550 eb0bae0 Thanks @​xianjianlf2! - Return fresh OpenAPI specs from cached OpenApi.fromApi calls.

  • #7188 97b544d Thanks @​gcanti! - Mark the internal ~sentinels Schema annotation as @internal so release declaration stripping removes it together with SchemaAST.Sentinel. This keeps the published declarations self-consistent for consumers that type-check dependencies with skipLibCheck: false.

  • #7158 4f6d131 Thanks @​k3dom! - Improve Union candidate selection: a nested union member is dispatched by the sentinels common to all its members, and candidates whose sentinel the input contradicts are excluded.

  • #7178 fad4b7c Thanks @​tim-smart! - Use Promise microtasks for synchronous Scheduler dispatch.

  • #7181 accf447 Thanks @​gcanti! - Move SchemaError into the Schema module and remove the standalone SchemaError module.

  • #7195 31b27e4 Thanks @​tim-smart! - Ensure discarded non-persisted cluster messages complete without waiting for the entity reply.

  • #7191 8458951 Thanks @​Digifox03! - Fix HttpRouter.Middleware.layer to provide request error services for errors declared in handles, and expose global middleware errors from HttpRouter.toHttpEffect.

Changelog

Sourced from effect's changelog.

4.0.0-rc.108

Patch Changes

  • #6546 dfb173e Thanks @​xianjianlf2! - Handle BigInt values safely and consistently across JSON diagnostics and logger formats.

  • #7174 005e090 Thanks @​tim-smart! - Fix Queue.await failing with Cause.Done when registered before the queue ends.

  • #7180 c82c532 Thanks @​gcanti! - Prioritize redacted representations in formatters and normalize text logger levels to uppercase.

  • #7193 22b579f Thanks @​kitlangton! - Fix Deferred.await dying with a TypeError when a waiter is interrupted after the Deferred has been completed.

  • #7179 3e19539 Thanks @​tim-smart! - Fix DurableDeferred.raceAll so a completed deferred can wake an active workflow without changing success-biased race semantics

  • #7189 08a3c74 Thanks @​gcanti! - Fix HttpApi query decoding for array parameters with a single value.

  • #6550 eb0bae0 Thanks @​xianjianlf2! - Return fresh OpenAPI specs from cached OpenApi.fromApi calls.

  • #7188 97b544d Thanks @​gcanti! - Mark the internal ~sentinels Schema annotation as @internal so release declaration stripping removes it together with SchemaAST.Sentinel. This keeps the published declarations self-consistent for consumers that type-check dependencies with skipLibCheck: false.

  • #7158 4f6d131 Thanks @​k3dom! - Improve Union candidate selection: a nested union member is dispatched by the sentinels common to all its members, and candidates whose sentinel the input contradicts are excluded.

  • #7178 fad4b7c Thanks @​tim-smart! - Use Promise microtasks for synchronous Scheduler dispatch.

  • #7181 accf447 Thanks @​gcanti! - Move SchemaError into the Schema module and remove the standalone SchemaError module.

  • #7195 31b27e4 Thanks @​tim-smart! - Ensure discarded non-persisted cluster messages complete without waiting for the entity reply.

  • #7191 8458951 Thanks @​Digifox03! - Fix HttpRouter.Middleware.layer to provide request error services for errors declared in handles, and expose global middleware errors from HttpRouter.toHttpEffect.

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julien Goux --- apps/cli/package.json | 4 +- .../src/shared/telemetry/posthog-client.ts | 21 +- pnpm-lock.yaml | 235 +++++++++--------- pnpm-workspace.yaml | 10 +- 4 files changed, 136 insertions(+), 134 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index d5cc742b7e..b5f13f2a21 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,7 +43,7 @@ "jose": "^6.2.8" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.228", + "@anthropic-ai/claude-agent-sdk": "^0.3.229", "@anthropic-ai/sdk": "^0.116.0", "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", @@ -78,7 +78,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.23.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.48.1", + "posthog-node": "^5.48.2", "react": "^19.2.8", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index e44f5d2c63..28d187ed01 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -40,17 +40,12 @@ export const scopedPosthogClient = (apiKey: string, host: string) => return { client, shutdown }; }), ({ client, shutdown }) => - Effect.promise(async () => { - try { - await client._shutdown(EXIT_DELAY_CAP_MS); - } catch { - // The deadline rejection must be swallowed: Effect.promise turns - // rejections into defects, which would fail the command. - } finally { - // The shutdown deadline only stops the wait; the SDK's drain keeps - // in-flight requests running and starts queued ones after release. - // Aborting cancels them so nothing outlives the scope. - shutdown.abort(); - } - }), + Effect.promise(() => client.shutdown(30_000).catch(() => undefined)).pipe( + // Our Effect deadline precedes the SDK's noisy 30-second deadline. + Effect.timeoutOption(EXIT_DELAY_CAP_MS), + Effect.asVoid, + // The SDK drain can continue after the Effect deadline; aborting its + // fetches lets that background drain settle without active requests. + Effect.ensuring(Effect.sync(() => shutdown.abort())), + ), ).pipe(Effect.map(({ client }) => client)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2627512d5..c8a962b916 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,17 +10,17 @@ catalogs: specifier: 4.0.0-beta.107 version: 4.0.0-beta.107 '@effect/platform-bun': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 '@effect/platform-node': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 '@effect/sql-pg': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 '@effect/vitest': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 '@nx/devkit': specifier: ^23.1.1 version: 23.1.1 @@ -43,8 +43,8 @@ catalogs: specifier: ^4.1.10 version: 4.1.10 effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 knip: specifier: ^6.32.2 version: 6.32.2 @@ -119,8 +119,8 @@ importers: version: 6.2.8 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.228 - version: 0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.229 + version: 0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': specifier: ^0.116.0 version: 0.116.0(zod@4.4.3) @@ -129,16 +129,16 @@ importers: version: 1.7.0 '@effect/atom-react': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(react@19.2.8)(scheduler@0.27.0) + version: 4.0.0-beta.107(effect@4.0.0-rc.108)(react@19.2.8)(scheduler@0.27.0) '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + version: 4.0.0-rc.108(effect@4.0.0-rc.108) '@effect/sql-pg': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + version: 4.0.0-rc.108(effect@4.0.0-rc.108) '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10) + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10) '@modelcontextprotocol/sdk': specifier: ^1.30.0 version: 1.30.0(zod@4.4.3) @@ -195,7 +195,7 @@ importers: version: 17.4.2 effect: specifier: 'catalog:' - version: 4.0.0-beta.107 + version: 4.0.0-rc.108 esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -224,8 +224,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.48.1 - version: 5.48.1 + specifier: ^5.48.2 + version: 5.48.2 react: specifier: ^19.2.8 version: 19.2.8 @@ -351,13 +351,13 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + version: 4.0.0-rc.108(effect@4.0.0-rc.108) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) effect: specifier: 'catalog:' - version: 4.0.0-beta.107 + version: 4.0.0-rc.108 undici: specifier: ^8.10.0 version: 8.10.0 @@ -440,16 +440,16 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + version: 4.0.0-rc.108(effect@4.0.0-rc.108) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) dedent: specifier: ^1.7.2 version: 1.7.2 effect: specifier: 'catalog:' - version: 4.0.0-beta.107 + version: 4.0.0-rc.108 smol-toml: specifier: ^1.8.0 version: 1.8.0 @@ -486,14 +486,14 @@ importers: dependencies: effect: specifier: 'catalog:' - version: 4.0.0-beta.107 + version: 4.0.0-rc.108 devDependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + version: 4.0.0-rc.108(effect@4.0.0-rc.108) '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10) + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 @@ -526,20 +526,20 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + version: 4.0.0-rc.108(effect@4.0.0-rc.108) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) '@supabase/process-compose': specifier: workspace:* version: link:../process-compose effect: specifier: 'catalog:' - version: 4.0.0-beta.107 + version: 4.0.0-rc.108 devDependencies: '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10) + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10) '@supabase/supabase-js': specifier: ^2.112.3 version: 2.112.3 @@ -601,52 +601,52 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228': - resolution: {integrity: sha512-HuCsV3/5XuYYaWuCbksX+e0JkDDUG/AlFJ8wKhDL3PBW/3hHNd6xBYx88kEWk1Z6B1GLxwHht9624lcmscpsyw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229': + resolution: {integrity: sha512-yVrJwSG9ur001ggDHKPUHlRuHKwbi2ETfby+4wlktRcQ5sAXqcSeS6B4T+IsXk8NDDHKXbCxpAPnCB5dz1Ac9g==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228': - resolution: {integrity: sha512-jSUYY5Nd3efvbLZPU+i0tRBaFXskHu8M+4LMGBEw6A0PaklZ3YfGvKlTOWtJGRw6vMc6LzfOFts024xPNm6OrQ==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229': + resolution: {integrity: sha512-MOhOwgh9fqnX/rqeVJzy+NNFx+xXX2Kej/jx33xTV+4J5aJ2H1SwjZ+r3f/k1MMQmPvkrE0GHnMYrpu4hfvZ5Q==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228': - resolution: {integrity: sha512-4PgfisC3kHKlzJvy3rrm4Oh26g+D78h4ahHjni9fvSKHuJgrvHu9Qgo6aaYmzWdc7v9drL+pgiCk5Ge4Y2ANPA==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229': + resolution: {integrity: sha512-S0XcKhUXLFdEv1Pq+aYiV7fDlTa/n+bajBNkKv12eUzjCUFsM/TfSyU5shnkQg9y7NtoB4owK/rezm5hHD1ybg==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228': - resolution: {integrity: sha512-0Wjv6TiWwGlBZINAmNJX07jN359jKwB/4Sr/uWgQkdjuVIOhe/M8ydk7JL2EPqCsbiW1lc15NjE5MWpZiYqooA==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229': + resolution: {integrity: sha512-xSPUmKNik7HEYsbdUFywbjvGzUth0P2KohetJixSOrBha63ZRun449ssEHU55VX3qH+YO5ENn129l5TGFzMNGQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228': - resolution: {integrity: sha512-dnXxyiwGCZj27HVk6clYRqGMgrs3KVLVp0vvWYLjkPGBiKbI83qJiDpOfaekEXG2I4elX0M4XikggV1LGWjimg==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229': + resolution: {integrity: sha512-9gJsr0elKyV21ln+Mza5kYiSAsqSAWFiHuaT+MiXAycBK73UbzRVLBg87dajkGagg6H1u5ZtPSkqNXzA2xArmA==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228': - resolution: {integrity: sha512-LmGplObceqMOu5mlrlhTZL/VSrEWdZagF0Bl8awglMu6WeQcNe7StORYkCznZ0BuzV4CwuC3ipV4q8Jrs66wSg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229': + resolution: {integrity: sha512-Cs0NxWVL/Up8kEh//P2cE+3VHrDxoSmWUP3EvI/GU+1RRGZPDD80UN1Rvl3TMC5QPMq+IYPjh1yncnxZ6j8aNw==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228': - resolution: {integrity: sha512-mNS5yIMz/OXSQiDErb84jA8AKBFSlS9RSZ0qn2qyGkxplUx7kVmIDg/KnwOwHmygpzmH4UmR6OCaLXGohupqNA==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229': + resolution: {integrity: sha512-2fkljxQweZUa41RrlhLh+VdZrJ4kYzEBHGD8sc+Xy2bjkF8T4GTmsE8eKSWN3NfSCQOLHgK7Bxfm6+2n88WJMg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228': - resolution: {integrity: sha512-DYT3HvdS64Pq0IRvgW3RDO31yjYp5yiUKoKaZolTpLKfALpG5LI/osfnKlya68PZ/bSST1FNAfW9I0EtCnaQ4w==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229': + resolution: {integrity: sha512-2M2/KHJTGqzduA8eRZDu1vWSFkMqKl9JZMqJmaJ2THIR2g7jBUAzK0B57UzkO9uQXoXWFH7P91OSKKGA4rwfwQ==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.228': - resolution: {integrity: sha512-OOaME54VCoBLjKMqWqFmHkZGyL/x/FHUA0snhyolmyEhVoeBM0Ub5mrnV2Gx3d5/RcVlk2BnEVvPqu0SpZ9VFw==} + '@anthropic-ai/claude-agent-sdk@0.3.229': + resolution: {integrity: sha512-ZxSFO7cNSz7jqsOmoGkBtvMT4JGWb2a8PUe6doAydZXH6mm6FsoUf+duSUm2rDrbv7OMoD8HDGwNZv5P3woE3w==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -770,10 +770,10 @@ packages: react: '>=19.2.7 <20.0.0' scheduler: '>=0.27.0 <0.28.0' - '@effect/platform-bun@4.0.0-beta.107': - resolution: {integrity: sha512-nDKutCpgr+xHQX7tgN8Cq6JXtj96GqiElKaJ7AwAkZYl2q9f6onc/3aJgl6CyH/DlUoidw6YaxeU+UaTRmMN1g==} + '@effect/platform-bun@4.0.0-rc.108': + resolution: {integrity: sha512-27RoALzmzx6Qp4LrPIE8bYJfHe+8ZaAO3xLhJMEE6mVA8fxcPh4HAGwBv09Nk2qTFVh578SlSCY5ojUlqeSJ4A==} peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.108 '@effect/platform-node-shared@4.0.0-beta.107': resolution: {integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==} @@ -781,22 +781,22 @@ packages: peerDependencies: effect: ^4.0.0-beta.107 - '@effect/platform-node@4.0.0-beta.107': - resolution: {integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==} + '@effect/platform-node@4.0.0-rc.108': + resolution: {integrity: sha512-Nof78154BaHGdSYr4TPQFZ5+Dg+HkpmbI3SQUdwsby5QNs6yahGJPu2AgdIVqdx7pKZ2w7j/bvdnqoMmkG0PbA==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.108 ioredis: '>=5.7.0 <6.0.0' - '@effect/sql-pg@4.0.0-beta.107': - resolution: {integrity: sha512-y5RWMhdLhFqn0picXqZIR4Bevo4Fo+aT2xHNiyZoAR86d8CnbXp6Agq9HmXIGWS/nd5f7Bs4d7Aif7QUTsUofg==} + '@effect/sql-pg@4.0.0-rc.108': + resolution: {integrity: sha512-K7PZL+J71IDRsOu4CHtIJUCoDpsfD1G9Mdttn/jj9tCsQFA9ySIu2+IFmUlgJAvUUlfdg/OMhkocxkGoVZJTLw==} peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.108 - '@effect/vitest@4.0.0-beta.107': - resolution: {integrity: sha512-n4/qsx4DnT4dEI/wNgMivxyUeJoeiU1TCSz0WnoHWk/dny40Oxjip2P9IXGQDgPb9fsYVnerF0QRA6nPUuExQA==} + '@effect/vitest@4.0.0-rc.108': + resolution: {integrity: sha512-XD2GP1JATN28wnIeFGsBqYMuQDCHIodKKgFulGyG91GvuHqgf2gz+WxR2LPdyNoKMtr7lsbRzVj07niSYtIKzA==} peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.108 vitest: '>=4.1.0 <5.0.0' '@emnapi/core@1.11.1': @@ -1363,6 +1363,13 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@next/env@16.3.0': resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} @@ -2257,11 +2264,11 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@posthog/core@1.47.0': - resolution: {integrity: sha512-LW62V+9yx7G7mLd+EYpwW0PiaPZI8XRJ1tLuhw+9fXHpugLp8XQD5JuCQ2kIXvoUfyx1DpKfGQY3dUnzMOIn7Q==} + '@posthog/core@1.47.1': + resolution: {integrity: sha512-d38C5DulL3gCox4g0VGyb/Lhn548fBvj9f35LZGVasPspOs3jyApxROJyDXoWwIRivOjCOIShZQAdYkZVn9J3w==} - '@posthog/types@1.402.2': - resolution: {integrity: sha512-ZZTiS4dLwF4/D0YTzS3gGSLFnhuNOY5yu4d9VGS9trGe5GW6FjIXo20p18K5BPW2RZSYSld8sdnSAY3AUXleUQ==} + '@posthog/types@1.403.0': + resolution: {integrity: sha512-QbkO0epmdq38xhxwP214YRi0vgpZiXqhamaTjKT6kVGJYTtsE5qZ1GTgLp12IYehg/rd+whYYErH3/DeX8pj3Q==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -4120,8 +4127,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@4.0.0-beta.107: - resolution: {integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==} + effect@4.0.0-rc.108: + resolution: {integrity: sha512-KmI3DlKZWPvCL4QQ2FMaPOuxMt/7DrKMENCY/gQ+MkDR5QYw25wgU5Zmh/wVLboNjIci1gNOgNCFe4xqgxli3A==} ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} @@ -6106,8 +6113,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.48.1: - resolution: {integrity: sha512-BxLX2SqGEQhPqCPTalpyo0RRv1NMbf7UaN7q9d/ED77ksD6XOmE7ko2vIKO8F0zPL1NtKxIi+DYXap9lvR0RaA==} + posthog-node@5.48.2: + resolution: {integrity: sha512-3Ni5upqpbUXL1QM2oU4sL9Y3OycDGgrKI9aJOGHbcF9xkPwR18cq481Tkvn6/CCPbl04p3tjdwQNkGHVveQcag==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -7310,44 +7317,44 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.116.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.228 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.228 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.228 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.228 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.228 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.228 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.228 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.228 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.229 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.229 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.229 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.229 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.229 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.229 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.229 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.229 '@anthropic-ai/sdk@0.116.0(zod@4.4.3)': dependencies: @@ -7514,33 +7521,33 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@effect/atom-react@4.0.0-beta.107(effect@4.0.0-beta.107)(react@19.2.8)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.107(effect@4.0.0-rc.108)(react@19.2.8)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.108 react: 19.2.8 scheduler: 0.27.0 - '@effect/platform-bun@4.0.0-beta.107(effect@4.0.0-beta.107)': + '@effect/platform-bun@4.0.0-rc.108(effect@4.0.0-rc.108)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107) - effect: 4.0.0-beta.107 + '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-rc.108) + effect: 4.0.0-rc.108 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-beta.107)': + '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-rc.108)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.108 ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1)': + '@effect/platform-node@4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107) - effect: 4.0.0-beta.107 + '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-rc.108) + effect: 4.0.0-rc.108 ioredis: 5.11.1 mime: 4.1.0 undici: 8.10.0 @@ -7548,9 +7555,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107)': + '@effect/sql-pg@4.0.0-rc.108(effect@4.0.0-rc.108)': dependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.108 pg: 8.23.0 pg-connection-string: 2.14.0 pg-cursor: 2.21.0(pg@8.23.0) @@ -7559,9 +7566,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/vitest@4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)': + '@effect/vitest@4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10)': dependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.108 vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@emnapi/core@1.11.1': @@ -8016,17 +8023,17 @@ snapshots: '@emnapi/runtime': 1.4.5 '@tybys/wasm-util': 0.9.0 - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.3 optional: true @@ -8578,11 +8585,11 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@posthog/core@1.47.0': + '@posthog/core@1.47.1': dependencies: - '@posthog/types': 1.402.2 + '@posthog/types': 1.403.0 - '@posthog/types@1.402.2': {} + '@posthog/types@1.403.0': {} '@protobufjs/aspromise@1.1.2': {} @@ -8997,7 +9004,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -10357,7 +10364,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.107: + effect@4.0.0-rc.108: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 @@ -12829,9 +12836,9 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.48.1: + posthog-node@5.48.2: dependencies: - '@posthog/core': 1.47.0 + '@posthog/core': 1.47.1 pretty-ms@9.3.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba2a607316..412aa60a09 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,10 +14,10 @@ allowBuilds: catalog: "@effect/atom-react": "4.0.0-beta.107" - "@effect/platform-bun": "4.0.0-beta.107" - "@effect/platform-node": "4.0.0-beta.107" - "@effect/sql-pg": "4.0.0-beta.107" - "@effect/vitest": "4.0.0-beta.107" + "@effect/platform-bun": "4.0.0-rc.108" + "@effect/platform-node": "4.0.0-rc.108" + "@effect/sql-pg": "4.0.0-rc.108" + "@effect/vitest": "4.0.0-rc.108" "@nx/devkit": "^23.1.1" "@swc-node/register": "^1.12.1" "@swc/core": "^1.15.47" @@ -25,7 +25,7 @@ catalog: "@types/bun": "^1.3.14" "@typescript/native-preview": "7.0.0-dev.20260707.2" "@vitest/coverage-istanbul": "^4.1.10" - "effect": "4.0.0-beta.107" + "effect": "4.0.0-rc.108" "knip": "^6.32.2" "nx": "^23.1.1" "oxfmt": "^0.63.0" From 3fa484322aa678c32a56f142593a63223415ffc8 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:54:09 +0000 Subject: [PATCH 17/63] fix(cli): bypass loopback proxying (#6283) ## TL;DR Keep `supabase start` health probes to the local Kong gateway off HTTP(S) proxies restoring the previous Go CLI behavior for the CLI's canonical loopback addresses. ## What regressed? The native TypeScript port moved these probes from Go's `net/http` client to Bun's `fetch`. Go bypassed proxies for localhost and loopback addresses, while Bun honors `HTTP_PROXY` and `HTTPS_PROXY` unless `NO_PROXY` is configured... With a proxy configured, PostgREST and Edge Runtime readiness probes can be sent to the proxy instead of `127.0.0.1`, causing a healthy local stack to fail startup and roll back... ## fixed now by: Append `localhost`, `127.0.0.1`, and `[::1]` to Bun's active `NO_PROXY` variable immediately before `start` performs its local gateway probes. Existing exclusions are preserved, and the late placement keeps the synthetic value out of project dotenv resolution and container environments... ## Ref resolves: https://github.com/supabase/cli/issues/3265#issuecomment-5353163389 --- .../src/legacy/commands/start/SIDE_EFFECTS.md | 1 + .../legacy/commands/start/start.handler.ts | 3 + .../legacy/commands/start/start.live.test.ts | 64 +++++++++++++++++++ apps/cli/src/legacy/shared/legacy-hostname.ts | 8 +++ .../shared/legacy-hostname.unit.test.ts | 32 +++++++++- 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 4af5e52658..87d465136f 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -171,6 +171,7 @@ not implemented. | `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | | `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | +| `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 7d92e89285..fd9ffb2a73 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -47,6 +47,7 @@ import { legacyIsEncryptedSecret, } from "../../shared/legacy-vault-decrypt.ts"; import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; +import { legacyConfigureLoopbackProxyBypass } from "../../shared/legacy-hostname.ts"; import { legacyCliProjectFilterValue, legacyServiceContainerIds, @@ -1979,6 +1980,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); + // Keep the synthetic value out of project dotenv resolution and container environments. + legacyConfigureLoopbackProxyBypass(); const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { postgrest: postgrestGateway, edgeRuntime: edgeRuntimeGateway, diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.live.test.ts index 06a2dd05c5..fcfd71f2a0 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.live.test.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; +import { once } from "node:events"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -201,6 +203,68 @@ describeLive("supabase start (live)", () => { }, ); + test( + "bypasses an HTTPS proxy for loopback gateway health checks", + { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-proxy-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + let proxyConnections = 0; + const proxy = createServer((socket) => { + proxyConnections += 1; + socket.destroy(); + }); + + try { + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const address = proxy.address(); + if (address === null || typeof address === "string") { + throw new Error("Failed to allocate a proxy port"); + } + + const excludeArgs = LEGACY_SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || + entry.excludeKey === "kong" || + entry.excludeKey === "postgrest" + ? [] + : ["--exclude", entry.excludeKey], + ); + const proxyUrl = `http://127.0.0.1:${address.port}`; + const start = await runSupabaseLive(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: { + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: proxyUrl, + https_proxy: proxyUrl, + NO_PROXY: "", + no_proxy: "", + SUPABASE_API_TLS_ENABLED: "true", + SUPABASE_SERVICES_HOSTNAME: "127.0.0.1", + }, + }); + + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + expect(start.stdout).toContain("https://127.0.0.1:"); + expect(proxyConnections).toBe(0); + } finally { + if (proxy.listening) { + await new Promise((resolve, reject) => { + proxy.close((error) => (error === undefined ? resolve() : reject(error))); + }); + } + } + }, + ); + // The health watch inspects and dumps logs by container NAME against a real // daemon, and derives recovery advice from a real container's real log bytes. // Neither is observable through the in-process mocks, so this reproduces diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 783503d89b..8bafb29722 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; const LOCAL_HOST = "127.0.0.1"; +const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`; /** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ const DEFAULT_CONTEXT_NAME = "default"; @@ -141,3 +142,10 @@ export function legacyGetHostname(): string { } return LOCAL_HOST; } + +/** Keeps Bun from proxying the legacy CLI's loopback HTTP requests. */ +export function legacyConfigureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void { + const key = (env["no_proxy"]?.length ?? 0) > 0 ? "no_proxy" : "NO_PROXY"; + const current = env[key]; + env[key] = current ? `${current},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY; +} diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 0f1c351d0e..88656f8b2f 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { legacyGetHostname } from "./legacy-hostname.ts"; +import { legacyConfigureLoopbackProxyBypass, legacyGetHostname } from "./legacy-hostname.ts"; + +const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,[::1]"; function withEnv(entries: Record, run: () => T): T { const previous: Record = {}; @@ -186,3 +188,31 @@ describe("legacyGetHostname", () => { }); }); }); + +describe("legacyConfigureLoopbackProxyBypass", () => { + it.each([ + ["sets NO_PROXY when neither spelling is configured", {}, { NO_PROXY: LOOPBACK_NO_PROXY }], + [ + "preserves an existing NO_PROXY value", + { NO_PROXY: "example.com" }, + { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}` }, + ], + [ + "updates the non-empty lowercase value preferred by Bun", + { NO_PROXY: "uppercase.example", no_proxy: "lowercase.example" }, + { + NO_PROXY: "uppercase.example", + no_proxy: `lowercase.example,${LOOPBACK_NO_PROXY}`, + }, + ], + [ + "falls back to NO_PROXY when lowercase no_proxy is empty", + { NO_PROXY: "example.com", no_proxy: "" }, + { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}`, no_proxy: "" }, + ], + ])("%s", (_name, env, expected) => { + legacyConfigureLoopbackProxyBypass(env); + + expect(env).toEqual(expected); + }); +}); From ba107e7c53777e01548657058eaefa3af4de49dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:12:42 +0000 Subject: [PATCH 18/63] fix(deps): bump golang.org/x/mod from 0.39.0 to 0.40.0 in /apps/cli-go/pkg in the go-minor group across 1 directory (#6285) Bumps the go-minor group with 1 update in the /apps/cli-go/pkg directory: [golang.org/x/mod](https://github.com/golang/mod). Updates `golang.org/x/mod` from 0.39.0 to 0.40.0
Commits
  • d3398d0 go.mod: update golang.org/x dependencies
  • 57549bf sumdb: ignore unrelated hashes in Lookup
  • 96f62ae sumdb/tlog: fix TileHashReader authentication bypass
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/mod&package-manager=go_modules&previous-version=0.39.0&new-version=0.40.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/go.mod | 2 +- apps/cli-go/pkg/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 3cf35f5bc0..ed8442ed96 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -25,7 +25,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.0 github.com/tidwall/jsonc v0.3.3 - golang.org/x/mod v0.39.0 + golang.org/x/mod v0.40.0 google.golang.org/grpc v1.83.0 ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index 21c9a48b9c..159c4eb540 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -220,8 +220,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= -golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= From bf9759120a19de5b67265df499f9a31bdcc5bb27 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 21 Aug 2026 11:13:21 +0000 Subject: [PATCH 19/63] chore(deps): consolidate TypeScript and update Effect (#6289) ## Summary - consolidate the monorepo on the cataloged TypeScript 7 package and replace native compiler inference with tsc - update the full Effect dependency family and cooldown exclusions to RC.111 without a shared-platform override - preserve CLI flag and legacy output behavior under RC.111, then refresh generated API and schema outputs ## Context This removes the parallel TypeScript aliases and stale Effect beta/RC pins. Effect RC.111 also makes boolean defaults and response decoding stricter, so the affected CLI boundaries now state their existing behavior explicitly. --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- apps/cli-e2e/package.json | 3 +- apps/cli/package.json | 4 +- .../commands/completion/completion.flags.ts | 1 + .../commands/db/advisors/advisors.command.ts | 6 +- .../legacy/commands/db/diff/diff.command.ts | 1 + .../legacy/commands/db/dump/dump.command.ts | 2 + .../legacy/commands/db/lint/lint.command.ts | 2 + .../legacy/commands/db/pull/pull.command.ts | 1 + .../legacy/commands/db/push/push.command.ts | 15 +- .../legacy/commands/db/query/query.format.ts | 2 +- .../db/remote/changes/changes.command.ts | 5 +- .../db/remote/commit/commit.command.ts | 5 +- .../legacy/commands/db/reset/reset.command.ts | 3 + .../schema/declarative/declarative.shared.ts | 2 + .../declarative/generate/generate.command.ts | 2 + .../domains/activate/activate.command.ts | 1 + .../commands/domains/create/create.command.ts | 1 + .../commands/domains/delete/delete.command.ts | 1 + .../commands/domains/get/get.command.ts | 1 + .../domains/reverify/reverify.command.ts | 1 + .../functions/deploy/deploy.command.ts | 4 + .../functions/download/download.command.ts | 2 + .../commands/functions/serve/serve.command.ts | 6 +- .../gen/signing-key/signing-key.command.ts | 1 + .../commands/gen/types/types.command.ts | 3 + .../src/legacy/commands/init/init.command.ts | 6 + .../inspect/db/legacy-inspect-db-command.ts | 10 +- .../commands/inspect/report/report.command.ts | 10 +- .../legacy/commands/issue/issue.command.ts | 2 + .../src/legacy/commands/link/link.command.ts | 1 + .../legacy/commands/login/login.command.ts | 1 + .../commands/migration/down/down.command.ts | 1 + .../commands/migration/fetch/fetch.command.ts | 1 + .../commands/migration/list/list.command.ts | 1 + .../migration/repair/repair.command.ts | 1 + .../migration/squash/squash.command.ts | 1 + .../commands/migration/up/up.command.ts | 2 + .../update/update.command.ts | 2 + .../postgres-config/delete/delete.command.ts | 1 + .../postgres-config/update/update.command.ts | 2 + .../projects/api-keys/api-keys.command.ts | 1 + .../src/legacy/commands/seed/seed.flags.ts | 5 +- .../ssl-enforcement/update/update.command.ts | 2 + .../legacy/commands/sso/add/add.command.ts | 1 + .../sso/list/list.integration.test.ts | 35 +- .../sso/remove/remove.integration.test.ts | 45 +- .../legacy/commands/sso/show/show.command.ts | 5 +- .../cli/src/legacy/commands/sso/sso.format.ts | 12 +- .../legacy/commands/sso/sso.metadata-url.ts | 2 +- .../commands/sso/update/update.command.ts | 1 + .../legacy/commands/start/start.command.ts | 2 + .../legacy/commands/status/status.command.ts | 1 + .../src/legacy/commands/stop/stop.command.ts | 1 + .../legacy/commands/storage/cp/cp.command.ts | 1 + .../legacy/commands/storage/ls/ls.command.ts | 1 + .../legacy/commands/storage/mv/mv.command.ts | 1 + .../legacy/commands/storage/rm/rm.command.ts | 1 + .../legacy/commands/storage/storage.flags.ts | 1 + .../shared/legacy-pgdelta-ssl-probe.layer.ts | 9 +- .../shared/legacy-test-db.command-handler.ts | 2 + .../branches/create/create.command.ts | 2 + .../functions/deploy/deploy.command.ts | 9 +- .../commands/functions/dev/dev.command.ts | 1 + .../functions/download/download.command.ts | 2 + .../src/next/commands/init/init.command.ts | 5 +- .../src/next/commands/issue/issue.command.ts | 2 + .../src/next/commands/login/login.command.ts | 1 + .../next/commands/logout/logout.command.ts | 5 +- .../src/next/commands/logs/logs.command.ts | 1 + .../next/commands/platform/request.command.ts | 3 + apps/cli/src/shared/legacy/global-flags.ts | 16 +- .../error-actionability-coverage.unit.test.ts | 131 ++- apps/docs/package.json | 25 +- docs/nx-inference-plugins.md | 16 +- nx.json | 2 +- package.json | 5 +- packages/api/package.json | 3 +- packages/api/scripts/generate.ts | 15 +- packages/api/scripts/generate.unit.test.ts | 2 +- packages/api/src/effect.unit.test.ts | 36 +- packages/api/src/generated/contracts.ts | 124 +-- packages/cli-test-helpers/package.json | 3 +- packages/config/package.json | 3 +- packages/config/src/io.unit.test.ts | 1 - packages/config/src/lib/env.unit.test.ts | 4 +- packages/process-compose/package.json | 3 +- packages/stack/package.json | 3 +- pnpm-lock.yaml | 904 ++++-------------- pnpm-workspace.yaml | 32 +- tools/nx-plugins/package.json | 3 +- tools/nx-plugins/src/knip.plugin.ts | 2 +- tools/nx-plugins/src/oxfmt.plugin.ts | 2 +- tools/nx-plugins/src/oxlint.plugin.ts | 2 +- .../{tsgo.plugin.ts => typescript.plugin.ts} | 12 +- 96 files changed, 719 insertions(+), 911 deletions(-) rename tools/nx-plugins/src/{tsgo.plugin.ts => typescript.plugin.ts} (75%) diff --git a/AGENTS.md b/AGENTS.md index edfd1fc3be..878e6ab5be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ These workspaces should generally follow this structure: - `name`: `@supabase/` - `type`: `"module"` - Standard scripts: `test`, `types:check`, `lint:check`, `lint:fix`, `fmt:check`, `fmt:fix`, `knip:check`, `knip:fix` -- Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `@typescript/native-preview`, `knip`, `oxfmt`, `oxlint`, `oxlint-tsgolint` +- Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `typescript`, `knip`, `oxfmt`, `oxlint`, `oxlint-tsgolint` Expected exceptions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecf23899f4..4ea9a7eac9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,7 +152,7 @@ All standard TypeScript workspaces (`apps/cli`, `packages/api`, `packages/config | `test:e2e` | Run end-to-end tests _(inferred by Nx plugin)_ | | `check:all` | Run all check targets for this project | | `fix:all` | Run all fix targets for this project | -| `types:check` | Type-check with `tsgo --noEmit` _(inferred by Nx plugin)_ | +| `types:check` | Type-check with `tsc --noEmit` _(inferred by Nx plugin)_ | | `lint:check` | Check for lint errors with `oxlint` _(inferred by Nx plugin)_ | | `lint:fix` | Auto-fix lint errors _(inferred by Nx plugin)_ | | `fmt:check` | Check formatting with `oxfmt --check` _(inferred by Nx plugin)_ | diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index 7c7167ad63..84261f76a4 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -19,12 +19,12 @@ "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "knip": { @@ -38,7 +38,6 @@ "fixtures/**" ], "ignoreDependencies": [ - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint" diff --git a/apps/cli/package.json b/apps/cli/package.json index b5f13f2a21..8cac32db58 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -64,7 +64,6 @@ "@types/pg": "^8.21.0", "@types/pg-copy-streams": "^1.2.5", "@types/react": "^19.2.18", - "@typescript/native-preview": "catalog:", "@vercel/detect-agent": "^1.2.5", "@vitest/coverage-istanbul": "catalog:", "dotenv": "^17.4.2", @@ -84,7 +83,7 @@ "semantic-release": "^25.0.9", "smol-toml": "^1.8.0", "tldts": "catalog:", - "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript": "catalog:", "vitest": "catalog:", "yaml": "^2.9.0" }, @@ -137,7 +136,6 @@ "@parcel/watcher-linux-x64-musl", "@parcel/watcher-win32-arm64", "@parcel/watcher-win32-x64", - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint", diff --git a/apps/cli/src/legacy/commands/completion/completion.flags.ts b/apps/cli/src/legacy/commands/completion/completion.flags.ts index 29e1193022..f5b03c012f 100644 --- a/apps/cli/src/legacy/commands/completion/completion.flags.ts +++ b/apps/cli/src/legacy/commands/completion/completion.flags.ts @@ -14,4 +14,5 @@ import { Flag } from "effect/unstable/cli"; */ export const LegacyCompletionNoDescriptionsFlagDef = Flag.boolean("no-descriptions").pipe( Flag.withDescription("disable completion descriptions"), + Flag.withDefault(false), ); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts index 98e8b8af01..0eccc61f30 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts @@ -14,8 +14,12 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Checks the linked project for issues."), + Flag.withDefault(false), + ), + local: Flag.boolean("local").pipe( + Flag.withDescription("Checks the local database for issues."), + Flag.withDefault(false), ), - local: Flag.boolean("local").pipe(Flag.withDescription("Checks the local database for issues.")), // TS-only override of the linked project ref — see push.command.ts. projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), diff --git a/apps/cli/src/legacy/commands/db/diff/diff.command.ts b/apps/cli/src/legacy/commands/db/diff/diff.command.ts index 526ea1129b..9a29292a3c 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -39,6 +39,7 @@ const config = { Flag.withDescription( "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", ), + Flag.withDefault(false), ), from: Flag.string("from").pipe( Flag.withDescription("Diff from local, linked, migrations, or a Postgres URL."), diff --git a/apps/cli/src/legacy/commands/db/dump/dump.command.ts b/apps/cli/src/legacy/commands/db/dump/dump.command.ts index 9725c24f7c..770f390aa3 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.command.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.command.ts @@ -32,6 +32,7 @@ const onRunFailure = (error: LegacyDbDumpRunError) => const config = { dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription("Prints the pg_dump script that would be executed."), + Flag.withDefault(false), ), // The boolean flags in mutually-exclusive groups (`data-only`/`role-only`/ // `keep-comments` and the `db-url`/`linked`/`local` target group) are @@ -46,6 +47,7 @@ const config = { ), useCopy: Flag.boolean("use-copy").pipe( Flag.withDescription("Use copy statements in place of inserts."), + Flag.withDefault(false), ), exclude: Flag.string("exclude").pipe( Flag.withAlias("x"), diff --git a/apps/cli/src/legacy/commands/db/lint/lint.command.ts b/apps/cli/src/legacy/commands/db/lint/lint.command.ts index 11b44c2e77..6910ca2679 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.command.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.command.ts @@ -15,9 +15,11 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Lints the linked project for schema errors."), + Flag.withDefault(false), ), local: Flag.boolean("local").pipe( Flag.withDescription("Lints the local database for schema errors."), + Flag.withDefault(false), ), // TS-only override of the linked project ref — see push.command.ts. projectRef: Flag.string("project-ref").pipe( diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index aff7d8cd91..eadd55b80b 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -38,6 +38,7 @@ const config = { Flag.withDescription( "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", ), + Flag.withDefault(false), ), schema: Flag.string("schema").pipe( Flag.withAlias("s"), diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 22e9547ff9..252920d237 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -9,20 +9,25 @@ import { legacyDbPushRuntimeLayer } from "./push.layers.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( Flag.withDescription("Include all migrations not found on remote history table."), + Flag.withDefault(false), ), includeRoles: Flag.boolean("include-roles").pipe( Flag.withDescription("Include custom roles from supabase/roles.sql."), + Flag.withDefault(false), ), includeSeed: Flag.boolean("include-seed").pipe( Flag.withDescription("Include seed data from your config."), + Flag.withDefault(false), ), skipVault: Flag.boolean("skip-vault").pipe( Flag.withDescription("Skip updating vault secrets from config.toml."), + Flag.withDefault(false), ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription( "Print the migrations that would be applied, but don't actually apply them.", ), + Flag.withDefault(false), ), dbUrl: Flag.string("db-url").pipe( Flag.withDescription( @@ -30,8 +35,14 @@ const config = { ), Flag.optional, ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Pushes to the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Pushes to the local database.")), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Pushes to the linked project."), + Flag.withDefault(false), + ), + local: Flag.boolean("local").pipe( + Flag.withDescription("Pushes to the local database."), + Flag.withDefault(false), + ), // TS-only flag on every user-facing `db` subcommand (Go's user-facing `db` // commands never registered --project-ref; only the SUPABASE_PROJECT_ID env // var could override the linked ref). The one Go exception is a hidden seam, diff --git a/apps/cli/src/legacy/commands/db/query/query.format.ts b/apps/cli/src/legacy/commands/db/query/query.format.ts index e810cc6be9..0f8350a3a4 100644 --- a/apps/cli/src/legacy/commands/db/query/query.format.ts +++ b/apps/cli/src/legacy/commands/db/query/query.format.ts @@ -5,7 +5,7 @@ import { legacyStringWidth } from "../../../shared/legacy-rune-width.ts"; // `JSON.rawJSON` (ES2025, present in Bun) wraps a string so `JSON.stringify` emits it // verbatim as a number/literal token — used to serialize int8/bigint exactly, beyond -// JS number precision. tsgo's bundled lib does not yet declare it. +// JS number precision. TypeScript's bundled lib does not yet declare it. declare global { interface JSON { rawJSON(text: string): unknown; diff --git a/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts b/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts index af081b5d94..7078ec0f2a 100644 --- a/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts +++ b/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts @@ -12,7 +12,10 @@ const config = { Flag.withDescription("Connect using the specified Postgres URL (must be percent-encoded)."), Flag.optional, ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Connect to the linked project.")), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Connect to the linked project."), + Flag.withDefault(false), + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), diff --git a/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts b/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts index fe9fe6d429..5fb94631cd 100644 --- a/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts @@ -12,7 +12,10 @@ const config = { Flag.withDescription("Connect using the specified Postgres URL (must be percent-encoded)."), Flag.optional, ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Connect to the linked project.")), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Connect to the linked project."), + Flag.withDefault(false), + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 2dacc5df5a..434fb7f948 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -17,9 +17,11 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Resets the linked project with local migrations."), + Flag.withDefault(false), ), local: Flag.boolean("local").pipe( Flag.withDescription("Resets the local database with local migrations."), + Flag.withDefault(false), ), // TS-only override of the linked project ref — see push.command.ts. projectRef: Flag.string("project-ref").pipe( @@ -28,6 +30,7 @@ const config = { ), noSeed: Flag.boolean("no-seed").pipe( Flag.withDescription("Skip running the seed script after reset."), + Flag.withDefault(false), ), sqlPaths: Flag.string("sql-paths").pipe( Flag.atLeast(0), diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts index 5f5a36be94..b6f8531a64 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts @@ -16,11 +16,13 @@ export const legacyDbSchemaDeclarativeSharedBase = Command.make("declarative").p Command.withSharedFlags({ noCache: Flag.boolean("no-cache").pipe( Flag.withDescription("Disable catalog cache and force fresh shadow database setup."), + Flag.withDefault(false), ), strictCoverage: Flag.boolean("strict-coverage").pipe( Flag.withDescription( "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", ), + Flag.withDefault(false), ), }), ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts index d2262ce571..4732ed6ee9 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts @@ -14,6 +14,7 @@ import { legacyDbSchemaDeclarativeGenerateRuntimeLayer } from "./generate.layers const config = { overwrite: Flag.boolean("overwrite").pipe( Flag.withDescription("Overwrite declarative schema files without confirmation."), + Flag.withDefault(false), ), // Deliberately NOT named `--output`/`-o`: the legacy root reserves those for // the global machine-format flag (`LegacyOutputFlag`, `json|yaml|toml|env|…`), @@ -30,6 +31,7 @@ const config = { ), reset: Flag.boolean("reset").pipe( Flag.withDescription("Reset local database before generating (local data will be lost)."), + Flag.withDefault(false), ), schema: Flag.string("schema").pipe( Flag.withAlias("s"), diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.command.ts b/apps/cli/src/legacy/commands/domains/activate/activate.command.ts index 331ebd49f3..67ad49374b 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.command.ts @@ -13,6 +13,7 @@ const config = { ), includeRawOutput: Flag.boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/domains/create/create.command.ts b/apps/cli/src/legacy/commands/domains/create/create.command.ts index 5824fac7ee..69750ae343 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.command.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.command.ts @@ -16,6 +16,7 @@ const config = { ), includeRawOutput: Flag.boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/domains/delete/delete.command.ts b/apps/cli/src/legacy/commands/domains/delete/delete.command.ts index 05eeed92b6..45515b4946 100644 --- a/apps/cli/src/legacy/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/domains/delete/delete.command.ts @@ -13,6 +13,7 @@ const config = { ), includeRawOutput: Flag.boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/domains/get/get.command.ts b/apps/cli/src/legacy/commands/domains/get/get.command.ts index 49cece3592..4676f27267 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.command.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.command.ts @@ -13,6 +13,7 @@ const config = { ), includeRawOutput: Flag.boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts index 249b0ff7e1..74f45b3ca4 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts @@ -13,6 +13,7 @@ const config = { ), includeRawOutput: Flag.boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts index 90f1df040f..d7a771dad1 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts @@ -19,9 +19,11 @@ const config = { ), noVerifyJwt: Flag.boolean("no-verify-jwt").pipe( Flag.withDescription("Disable JWT verification for the Function."), + Flag.withDefault(false), ), useApi: Flag.boolean("use-api").pipe( Flag.withDescription("Bundle functions server-side without using Docker."), + Flag.withDefault(false), ), importMap: Flag.string("import-map").pipe( Flag.withDescription("Path to import map file."), @@ -29,6 +31,7 @@ const config = { ), prune: Flag.boolean("prune").pipe( Flag.withDescription("Delete Functions that exist in Supabase project but not locally."), + Flag.withDefault(false), ), jobs: Flag.integer("jobs").pipe( Flag.withAlias("j"), @@ -46,6 +49,7 @@ const config = { ), legacyBundle: Flag.boolean("legacy-bundle").pipe( Flag.withDescription("Use legacy bundling."), + Flag.withDefault(false), Flag.withHidden, ), } as const; diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts index e7cb3d5ad5..134b8a5c0f 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.command.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts @@ -17,6 +17,7 @@ const config = { ), useApi: Flag.boolean("use-api").pipe( Flag.withDescription("Unbundle functions server-side without using Docker."), + Flag.withDefault(false), ), useDocker: Flag.boolean("use-docker").pipe( Flag.withDescription("Use Docker to unbundle functions locally."), @@ -25,6 +26,7 @@ const config = { ), legacyBundle: Flag.boolean("legacy-bundle").pipe( Flag.withDescription("Use legacy bundling."), + Flag.withDefault(false), Flag.withHidden, ), } as const; diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index 8915f180b2..dd2d4c5305 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -36,13 +36,17 @@ const config = { Flag.withDescription("Path to import map file."), Flag.optional, ), - inspect: Flag.boolean("inspect").pipe(Flag.withDescription("Alias of --inspect-mode brk.")), + inspect: Flag.boolean("inspect").pipe( + Flag.withDescription("Alias of --inspect-mode brk."), + Flag.withDefault(false), + ), inspectMode: Flag.choice("inspect-mode", FUNCTIONS_SERVE_INSPECT_MODES).pipe( Flag.withDescription("Activate inspector capability for debugging."), Flag.optional, ), inspectMain: Flag.boolean("inspect-main").pipe( Flag.withDescription("Allow inspecting the main worker."), + Flag.withDefault(false), ), all: Flag.boolean("all").pipe( Flag.withDescription("Serve all Functions."), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts index 83bc33b38d..489a9fc5ac 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts @@ -19,6 +19,7 @@ const config = { ), append: Flag.boolean("append").pipe( Flag.withDescription("Append new key to existing keys file instead of overwriting."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/gen/types/types.command.ts b/apps/cli/src/legacy/commands/gen/types/types.command.ts index 113a3a5413..4ac302688c 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.command.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.command.ts @@ -12,9 +12,11 @@ const SWIFT_ACCESS_CONTROL_VALUES = ["internal", "public"] as const; const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Generate types from the local dev database."), + Flag.withDefault(false), ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Generate types from the linked project."), + Flag.withDefault(false), ), dbUrl: Flag.string("db-url").pipe( Flag.withDescription("Generate types from a database url."), @@ -43,6 +45,7 @@ const config = { ), postgrestV9Compat: Flag.boolean("postgrest-v9-compat").pipe( Flag.withDescription("Generate types compatible with PostgREST v9 and below."), + Flag.withDefault(false), ), queryTimeout: Flag.string("query-timeout").pipe( Flag.withDescription("Maximum timeout allowed for the database query. (default 15s)"), diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index d66bb38a1d..4b968ecd0b 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.command.ts @@ -11,24 +11,30 @@ const config = { interactive: Flag.boolean("interactive").pipe( Flag.withDescription("Enables interactive mode to configure IDE settings."), Flag.withAlias("i"), + Flag.withDefault(false), ), useOrioledb: Flag.boolean("use-orioledb").pipe( Flag.withDescription("Use OrioleDB storage engine for Postgres."), + Flag.withDefault(false), ), force: Flag.boolean("force").pipe( Flag.withDescription("Overwrite existing supabase/config.toml."), + Flag.withDefault(false), ), withVscodeWorkspace: Flag.boolean("with-vscode-workspace").pipe( Flag.withDescription("Generate VS Code workspace."), Flag.withHidden, + Flag.withDefault(false), ), withVscodeSettings: Flag.boolean("with-vscode-settings").pipe( Flag.withDescription("Generate VS Code settings for Deno."), Flag.withHidden, + Flag.withDefault(false), ), withIntellijSettings: Flag.boolean("with-intellij-settings").pipe( Flag.withDescription("Generate IntelliJ IDEA settings for Deno."), Flag.withHidden, + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts index f8800d7d4b..e18d3c002a 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts @@ -18,8 +18,14 @@ export const LEGACY_INSPECT_DB_FLAGS = { ), Flag.optional, ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Inspect the linked project."), + Flag.withDefault(false), + ), + local: Flag.boolean("local").pipe( + Flag.withDescription("Inspect the local database."), + Flag.withDefault(false), + ), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), diff --git a/apps/cli/src/legacy/commands/inspect/report/report.command.ts b/apps/cli/src/legacy/commands/inspect/report/report.command.ts index 6d699f50b4..d513921d8d 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.command.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.command.ts @@ -13,8 +13,14 @@ const config = { ), Flag.optional, ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Inspect the linked project."), + Flag.withDefault(false), + ), + local: Flag.boolean("local").pipe( + Flag.withDescription("Inspect the local database."), + Flag.withDefault(false), + ), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), diff --git a/apps/cli/src/legacy/commands/issue/issue.command.ts b/apps/cli/src/legacy/commands/issue/issue.command.ts index 0d77da0067..8da66b72fa 100644 --- a/apps/cli/src/legacy/commands/issue/issue.command.ts +++ b/apps/cli/src/legacy/commands/issue/issue.command.ts @@ -8,6 +8,7 @@ import { legacyIssueBug, legacyIssueDocs, legacyIssueFeature } from "./issue.han const legacyIssueNoBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Print the issue form URL without opening a browser."), + Flag.withDefault(false), ); const legacyIssueOptionalTextFlag = (name: string, description: string) => @@ -39,6 +40,7 @@ const legacyIssueBugConfig = { const legacyIssueFeatureConfig = { existingIssues: Flag.boolean("existing-issues").pipe( Flag.withDescription("Prefill the existing issues checklist."), + Flag.withDefault(false), ), area: legacyIssueOptionalTextFlag("area", "Affected CLI area."), problem: legacyIssueOptionalTextFlag("problem", "Problem the feature should solve."), diff --git a/apps/cli/src/legacy/commands/link/link.command.ts b/apps/cli/src/legacy/commands/link/link.command.ts index 95d29e19e5..509fb95155 100644 --- a/apps/cli/src/legacy/commands/link/link.command.ts +++ b/apps/cli/src/legacy/commands/link/link.command.ts @@ -28,6 +28,7 @@ const config = { ), skipPooler: Flag.boolean("skip-pooler").pipe( Flag.withDescription("Use direct connection instead of pooler."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/login/login.command.ts b/apps/cli/src/legacy/commands/login/login.command.ts index 869cdb0516..660995b003 100644 --- a/apps/cli/src/legacy/commands/login/login.command.ts +++ b/apps/cli/src/legacy/commands/login/login.command.ts @@ -17,6 +17,7 @@ const config = { ), noBrowser: Flag.boolean("no-browser").pipe( Flag.withDescription("Do not open browser automatically."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/migration/down/down.command.ts b/apps/cli/src/legacy/commands/migration/down/down.command.ts index a9b02ae17e..2e4ea0081d 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.command.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.command.ts @@ -31,6 +31,7 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Resets applied migrations on the linked project."), + Flag.withDefault(false), ), local: Flag.boolean("local").pipe( Flag.withDescription("Resets applied migrations on the local database."), diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts index 6a25c75cd3..f630284159 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts @@ -19,6 +19,7 @@ const config = { ), local: Flag.boolean("local").pipe( Flag.withDescription("Fetches migration history from the local database."), + Flag.withDefault(false), ), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( diff --git a/apps/cli/src/legacy/commands/migration/list/list.command.ts b/apps/cli/src/legacy/commands/migration/list/list.command.ts index 3116909bfc..c96d2c5715 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.command.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.command.ts @@ -19,6 +19,7 @@ const config = { ), local: Flag.boolean("local").pipe( Flag.withDescription("Lists migrations applied to the local database."), + Flag.withDefault(false), ), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts index c698793b63..ad677843ce 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts @@ -25,6 +25,7 @@ const config = { ), local: Flag.boolean("local").pipe( Flag.withDescription("Repairs the migration history of the local database."), + Flag.withDefault(false), ), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts index 4cc3d9dc9e..8c99f914d6 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts @@ -19,6 +19,7 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Squashes the migration history of the linked project."), + Flag.withDefault(false), ), local: Flag.boolean("local").pipe( Flag.withDescription("Squashes the migration history of the local database."), diff --git a/apps/cli/src/legacy/commands/migration/up/up.command.ts b/apps/cli/src/legacy/commands/migration/up/up.command.ts index e12913d255..0e1a6362c1 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.command.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.command.ts @@ -9,6 +9,7 @@ import { legacyMigrationUp } from "./up.handler.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( Flag.withDescription("Include all migrations not found on remote history table."), + Flag.withDefault(false), ), dbUrl: Flag.string("db-url").pipe( Flag.withDescription( @@ -18,6 +19,7 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Applies pending migrations to the linked project."), + Flag.withDefault(false), ), local: Flag.boolean("local").pipe( Flag.withDescription("Applies pending migrations to the local database."), diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts index 86c533b5e4..fcad924436 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts @@ -35,9 +35,11 @@ const config = { dbAllowCidr: legacyNetworkRestrictionsUpdateDbAllowCidrFlag, bypassCidrChecks: Flag.boolean("bypass-cidr-checks").pipe( Flag.withDescription("Bypass some of the CIDR validation checks."), + Flag.withDefault(false), ), append: Flag.boolean("append").pipe( Flag.withDescription("Append to existing restrictions instead of replacing them."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts index 3197fb5fd1..8122cc50a1 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts @@ -30,6 +30,7 @@ const config = { config: legacyPostgresConfigDeleteConfigFlag, noRestart: Flag.boolean("no-restart").pipe( Flag.withDescription("Do not restart the database after deleting config."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts b/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts index 5de4e706f1..89d135de0c 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts @@ -32,9 +32,11 @@ const config = { Flag.withDescription( "If true, replaces all existing overrides with the ones provided. If false (default), merges existing overrides with the ones provided.", ), + Flag.withDefault(false), ), noRestart: Flag.boolean("no-restart").pipe( Flag.withDescription("Do not restart the database after updating config."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts index daf45197f2..8d0329dc3a 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts @@ -12,6 +12,7 @@ const config = { ), reveal: Flag.boolean("reveal").pipe( Flag.withDescription("Reveal the secret API keys in full (e.g. sb_secret_...)."), + Flag.withDefault(false), ), }; export type LegacyProjectsApiKeysFlags = CliCommand.Command.Config.Infer; diff --git a/apps/cli/src/legacy/commands/seed/seed.flags.ts b/apps/cli/src/legacy/commands/seed/seed.flags.ts index fe3aa4c86e..46b3048b03 100644 --- a/apps/cli/src/legacy/commands/seed/seed.flags.ts +++ b/apps/cli/src/legacy/commands/seed/seed.flags.ts @@ -16,7 +16,10 @@ import { Flag, GlobalFlag } from "effect/unstable/cli"; * telemetry flags map. */ export const LegacySeedLinkedFlag = GlobalFlag.setting("linked")({ - flag: Flag.boolean("linked").pipe(Flag.withDescription("Seeds the linked project.")), + flag: Flag.boolean("linked").pipe( + Flag.withDescription("Seeds the linked project."), + Flag.withDefault(false), + ), }); export const LegacySeedLocalFlag = GlobalFlag.setting("local")({ diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts index c9bdff5164..11d16bbea0 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts @@ -21,11 +21,13 @@ const config = { Flag.withDescription( "Whether the DB should enable SSL enforcement for all external connections.", ), + Flag.withDefault(false), ), disableDbSslEnforcement: Flag.boolean("disable-db-ssl-enforcement").pipe( Flag.withDescription( "Whether the DB should disable SSL enforcement for all external connections.", ), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.ts b/apps/cli/src/legacy/commands/sso/add/add.command.ts index a908440f1f..ab975472d7 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.command.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.command.ts @@ -43,6 +43,7 @@ const config = { Flag.withDescription( "Skip local validation of the SAML 2.0 Metadata URL (HTTPS requirement, live GET probe, and UTF-8 body decode). Use in air-gapped CI where the IDP is not reachable from the build agent.", ), + Flag.withDefault(false), ), attributeMappingFile: Flag.string("attribute-mapping-file").pipe( Flag.withDescription( diff --git a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts index e50654461e..2d847d7d64 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts @@ -14,6 +14,7 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { EventUpgradeSuggested } from "../../../../shared/telemetry/event-catalog.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { legacySsoList } from "./list.handler.ts"; // Mirrors what the Management API returns: neither `saml.id` nor @@ -158,14 +159,29 @@ describe("legacy sso list integration", () => { const item = { ...PROVIDER_ITEM, saml: { ...PROVIDER_ITEM.saml, id: "8682fcf4-4056-455c-bd93-f33295604929" }, - domains: [{ ...PROVIDER_ITEM.domains[0], id: "9484591c-a203-4500-bea7-d0aaa845e2f5" }], + domains: [ + { + ...PROVIDER_ITEM.domains[0], + id: "9484591c-a203-4500-bea7-d0aaa845e2f5", + created_at: "1999-01-02T03:04:05.000Z", + updated_at: "2000-02-03T04:05:06.000Z", + }, + ], }; const { layer, out } = setup({ goOutput: "json", body: { items: [item] } }); return Effect.gen(function* () { yield* legacySsoList({ projectRef: Option.none() }); + const emitted = JSON.parse(out.stdoutText) as { + providers: Array<{ domains: Array<{ created_at: string; updated_at: string }> }>; + }; expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc"); expect(out.stdoutText).not.toContain("8682fcf4-4056-455c-bd93-f33295604929"); expect(out.stdoutText).not.toContain("9484591c-a203-4500-bea7-d0aaa845e2f5"); + expect(emitted.providers[0]?.domains[0]).toEqual({ + domain: "example.com", + created_at: "1999-01-02T03:04:05.000Z", + updated_at: "2000-02-03T04:05:06.000Z", + }); }).pipe(Effect.provide(layer)); }); @@ -250,6 +266,23 @@ describe("legacy sso list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("rejects a 200 response containing a null provider item", () => { + const { layer } = setup({ body: { items: [null] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoListNetworkError"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacySsoListNetworkError:api_response", + }); + } + }).pipe(Effect.provide(layer)); + }); + it.live("Go --output wins over TS --output-format when both set", () => { const { layer, out } = setup({ format: "json", goOutput: "yaml" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts index 5b6181e6e9..4d60f0dac3 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts @@ -13,6 +13,7 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { EventUpgradeSuggested } from "../../../../shared/telemetry/event-catalog.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { legacySsoRemove } from "./remove.handler.ts"; const VALID_PROVIDER_ID = "b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8"; @@ -30,6 +31,7 @@ interface SetupOpts { goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; status?: number; body?: unknown; + rawBody?: string; network?: "fail"; upgradeGate?: "gated" | "notGated"; } @@ -38,10 +40,11 @@ function jsonResponse( request: Parameters[0], status: number, body: unknown, + rawBody?: string, ) { return HttpClientResponse.fromWeb( request, - new Response(JSON.stringify(body), { + new Response(rawBody ?? JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }), @@ -63,7 +66,7 @@ function setup(opts: SetupOpts = {}) { handler: (request) => { const url = request.url; if (url.includes("/config/auth/sso/providers/") && request.method === "DELETE") { - return Effect.succeed(jsonResponse(request, status, body)); + return Effect.succeed(jsonResponse(request, status, body, opts.rawBody)); } if (url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`)) { if (gate === undefined) return Effect.succeed(jsonResponse(request, 404, {})); @@ -253,4 +256,42 @@ describe("legacy sso remove integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.live("classifies malformed 200 response as an API status error", () => { + const { layer } = setup({ rawBody: "{not json" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoRemoveUnexpectedStatusError"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacySsoRemoveUnexpectedStatusError:api_status", + }); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a structurally invalid 200 response", () => { + const { layer } = setup({ body: {} }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoRemoveNetworkError"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacySsoRemoveNetworkError:api_response", + }); + } + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/legacy/commands/sso/show/show.command.ts b/apps/cli/src/legacy/commands/sso/show/show.command.ts index 2f12dc651a..cc159bea7f 100644 --- a/apps/cli/src/legacy/commands/sso/show/show.command.ts +++ b/apps/cli/src/legacy/commands/sso/show/show.command.ts @@ -11,7 +11,10 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - metadata: Flag.boolean("metadata").pipe(Flag.withDescription("Show SAML 2.0 XML Metadata only")), + metadata: Flag.boolean("metadata").pipe( + Flag.withDescription("Show SAML 2.0 XML Metadata only"), + Flag.withDefault(false), + ), providerId: Argument.string("provider-id").pipe( Argument.withDescription("The ID of the SSO identity provider to show."), ), diff --git a/apps/cli/src/legacy/commands/sso/sso.format.ts b/apps/cli/src/legacy/commands/sso/sso.format.ts index b609df9f82..a9330b2d4b 100644 --- a/apps/cli/src/legacy/commands/sso/sso.format.ts +++ b/apps/cli/src/legacy/commands/sso/sso.format.ts @@ -22,7 +22,11 @@ export interface LegacySsoProviderView { readonly name_id_format?: string; readonly attribute_mapping?: unknown; }; - readonly domains?: ReadonlyArray<{ readonly domain?: string }>; + readonly domains?: ReadonlyArray<{ + readonly domain?: string; + readonly created_at?: string; + readonly updated_at?: string; + }>; readonly created_at?: string; readonly updated_at?: string; } @@ -48,7 +52,11 @@ export function toLegacySsoProviderView(value: unknown): LegacySsoProviderView { const domains = Array.isArray(domainsRaw) ? domainsRaw .filter((d): d is Record => typeof d === "object" && d !== null) - .map((d) => ({ domain: typeof d["domain"] === "string" ? d["domain"] : undefined })) + .map((d) => ({ + domain: typeof d["domain"] === "string" ? d["domain"] : undefined, + created_at: typeof d["created_at"] === "string" ? d["created_at"] : undefined, + updated_at: typeof d["updated_at"] === "string" ? d["updated_at"] : undefined, + })) : undefined; return { diff --git a/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts b/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts index 5b92ead9b2..015ef86a3c 100644 --- a/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts +++ b/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts @@ -48,7 +48,7 @@ export const validateMetadataUrl = ( try: () => new URL(metadataUrl), catch: (cause) => new LegacySsoMetadataUrlInvalidError({ - message: `failed to parse metadata uri: ${String(cause)}`, + message: `failed to parse metadata uri ${JSON.stringify(metadataUrl)}: ${String(cause)}`, }), }); diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.ts b/apps/cli/src/legacy/commands/sso/update/update.command.ts index 61dd8f861f..dd9eee358c 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.ts @@ -50,6 +50,7 @@ const config = { Flag.withDescription( "Skip local validation of the SAML 2.0 Metadata URL (HTTPS requirement, live GET probe, and UTF-8 body decode). Use in air-gapped CI where the IDP is not reachable from the build agent.", ), + Flag.withDefault(false), ), attributeMappingFile: Flag.string("attribute-mapping-file").pipe( Flag.withDescription( diff --git a/apps/cli/src/legacy/commands/start/start.command.ts b/apps/cli/src/legacy/commands/start/start.command.ts index 5e54778ede..0d1ce71928 100644 --- a/apps/cli/src/legacy/commands/start/start.command.ts +++ b/apps/cli/src/legacy/commands/start/start.command.ts @@ -32,9 +32,11 @@ const config = { exclude: legacyStartExcludeFlag, ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), + Flag.withDefault(false), ), preview: Flag.boolean("preview").pipe( Flag.withDescription("Connect to feature preview branch"), + Flag.withDefault(false), Flag.withHidden, ), } as const; diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index 21854ca274..049745abeb 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -37,6 +37,7 @@ const config = { ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), Flag.withHidden, + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index 05e303fa2e..5a06d72289 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -24,6 +24,7 @@ const config = { ), noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), + Flag.withDefault(false), ), // Modelled as `Option` (presence = "explicitly set"), not a plain // boolean: `--project-id`/`--all` are mutually exclusive whenever BOTH flags diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts index 58f232a81c..78c0a88ee5 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -35,6 +35,7 @@ const config = { recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), Flag.withDescription("Recursively copy a directory."), + Flag.withDefault(false), ), cacheControl: Flag.string("cache-control").pipe( Flag.withDescription('Custom Cache-Control header for HTTP upload. (default "max-age=3600")'), diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts index 52d9e089ca..1de61e0de2 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts @@ -23,6 +23,7 @@ const config = { recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), Flag.withDescription("Recursively list a directory."), + Flag.withDefault(false), ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts index 32b489c46b..b321b83067 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts @@ -21,6 +21,7 @@ const config = { recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), Flag.withDescription("Recursively move a directory."), + Flag.withDefault(false), ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts index ebf625a252..3f5ede477e 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts @@ -23,6 +23,7 @@ const config = { recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), Flag.withDescription("Recursively remove a directory."), + Flag.withDefault(false), ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, diff --git a/apps/cli/src/legacy/commands/storage/storage.flags.ts b/apps/cli/src/legacy/commands/storage/storage.flags.ts index c36282a3e3..49b25047e7 100644 --- a/apps/cli/src/legacy/commands/storage/storage.flags.ts +++ b/apps/cli/src/legacy/commands/storage/storage.flags.ts @@ -26,6 +26,7 @@ export const LegacyStorageLinkedFlagDef = Flag.boolean("linked").pipe( export const LegacyStorageLocalFlagDef = Flag.boolean("local").pipe( Flag.withDescription("Connects to Storage API of the local database."), + Flag.withDefault(false), ); // TS-only override of the linked project ref — see push.command.ts (db push). diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts index 965d83e21c..fa8aea3c97 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts @@ -125,7 +125,14 @@ export const legacyPgDeltaSslProbeLayer = Layer.effect( ); socket.once("error", (err: Error) => settle( - Effect.fail(new LegacyPgDeltaSslProbeError({ message: err.message, cause: err })), + Effect.fail( + err.message.includes("ECONNRESET") || ("code" in err && err.code === "ECONNRESET") + ? new LegacyPgDeltaSslProbeError({ + message: `SSL probe connection to ${target.host}:${target.port} closed before the server responded`, + cause: err, + }) + : new LegacyPgDeltaSslProbeError({ message: err.message, cause: err }), + ), ), ); return Effect.sync(() => socket.destroy()); diff --git a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts index a15e437f29..d12270309e 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts @@ -68,9 +68,11 @@ export const legacyTestDbConfig = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Runs pgTAP tests on the linked project."), + Flag.withDefault(false), ), local: Flag.boolean("local").pipe( Flag.withDescription("Runs pgTAP tests on the local database."), + Flag.withDefault(false), ), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( diff --git a/apps/cli/src/next/commands/branches/create/create.command.ts b/apps/cli/src/next/commands/branches/create/create.command.ts index 237939a58e..5fad4fd6c9 100644 --- a/apps/cli/src/next/commands/branches/create/create.command.ts +++ b/apps/cli/src/next/commands/branches/create/create.command.ts @@ -77,9 +77,11 @@ const config = { ), persistent: Flag.boolean("persistent").pipe( Flag.withDescription("Create a persistent branch (default: ephemeral)."), + Flag.withDefault(false), ), withData: Flag.boolean("with-data").pipe( Flag.withDescription("Clone production data to the branch database."), + Flag.withDefault(false), ), notifyUrl: Flag.string("notify-url").pipe( Flag.withDescription("HTTP endpoint to notify when the branch becomes active and healthy."), diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts index df4061ca3b..2a60801b34 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts @@ -22,9 +22,11 @@ const config = { ), noVerifyJwt: Flag.boolean("no-verify-jwt").pipe( Flag.withDescription("Disable JWT verification for the Function."), + Flag.withDefault(false), ), useApi: Flag.boolean("use-api").pipe( Flag.withDescription("Bundle functions server-side without using Docker."), + Flag.withDefault(false), ), importMap: Flag.string("import-map").pipe( Flag.withDescription("Path to import map file."), @@ -32,8 +34,12 @@ const config = { ), prune: Flag.boolean("prune").pipe( Flag.withDescription("Delete Functions that exist in Supabase project but not locally."), + Flag.withDefault(false), + ), + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Skip the confirmation prompt."), + Flag.withDefault(false), ), - yes: Flag.boolean("yes").pipe(Flag.withDescription("Skip the confirmation prompt.")), jobs: Flag.integer("jobs").pipe( Flag.withAlias("j"), Flag.filter( @@ -51,6 +57,7 @@ const config = { legacyBundle: Flag.boolean("legacy-bundle").pipe( Flag.withDescription("Use legacy bundling mechanism."), Flag.withHidden, + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index 18708a6558..570aad4c63 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -21,6 +21,7 @@ const flags = { ), noVerifyJwt: Flag.boolean("no-verify-jwt").pipe( Flag.withDescription("Disable JWT verification for locally served Functions."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/next/commands/functions/download/download.command.ts b/apps/cli/src/next/commands/functions/download/download.command.ts index aecd8adc61..1d24797470 100644 --- a/apps/cli/src/next/commands/functions/download/download.command.ts +++ b/apps/cli/src/next/commands/functions/download/download.command.ts @@ -22,6 +22,7 @@ const config = { ), useApi: Flag.boolean("use-api").pipe( Flag.withDescription("Unbundle functions server-side without using Docker."), + Flag.withDefault(false), ), useDocker: Flag.boolean("use-docker").pipe( Flag.withDescription("Use Docker to unbundle functions client-side."), @@ -31,6 +32,7 @@ const config = { legacyBundle: Flag.boolean("legacy-bundle").pipe( Flag.withDescription("Use legacy bundling mechanism."), Flag.withHidden, + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/next/commands/init/init.command.ts b/apps/cli/src/next/commands/init/init.command.ts index 0d719d7d7e..e3ea6837ae 100644 --- a/apps/cli/src/next/commands/init/init.command.ts +++ b/apps/cli/src/next/commands/init/init.command.ts @@ -10,13 +10,16 @@ const config = { interactive: Flag.boolean("interactive").pipe( Flag.withDescription("Enables interactive mode to configure IDE settings."), Flag.withAlias("i"), + Flag.withDefault(false), ), - experimental: Flag.boolean("experimental").pipe(Flag.withHidden), + experimental: Flag.boolean("experimental").pipe(Flag.withHidden, Flag.withDefault(false)), useOrioledb: Flag.boolean("use-orioledb").pipe( Flag.withDescription("Use OrioleDB storage engine for Postgres."), + Flag.withDefault(false), ), force: Flag.boolean("force").pipe( Flag.withDescription("Overwrite existing supabase/config.toml."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/next/commands/issue/issue.command.ts b/apps/cli/src/next/commands/issue/issue.command.ts index 43afbb6fc5..7178e6d165 100644 --- a/apps/cli/src/next/commands/issue/issue.command.ts +++ b/apps/cli/src/next/commands/issue/issue.command.ts @@ -8,6 +8,7 @@ import { openBugIssue, openDocsIssue, openFeatureIssue } from "./issue.handler.t const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Print the issue form URL without opening a browser"), + Flag.withDefault(false), ); const optionalTextFlag = (name: string, description: string) => @@ -33,6 +34,7 @@ const bugFlags = { const featureFlags = { existingIssues: Flag.boolean("existing-issues").pipe( Flag.withDescription("Prefill the existing issues checklist"), + Flag.withDefault(false), ), area: optionalTextFlag("area", "Affected CLI area"), problem: optionalTextFlag("problem", "Problem the feature should solve"), diff --git a/apps/cli/src/next/commands/login/login.command.ts b/apps/cli/src/next/commands/login/login.command.ts index 8dcd40d6d0..6d8362dcfe 100644 --- a/apps/cli/src/next/commands/login/login.command.ts +++ b/apps/cli/src/next/commands/login/login.command.ts @@ -21,6 +21,7 @@ const flags = { ), noBrowser: Flag.boolean("no-browser").pipe( Flag.withDescription("Do not open browser automatically"), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/next/commands/logout/logout.command.ts b/apps/cli/src/next/commands/logout/logout.command.ts index e01cd852fa..a4b8635beb 100644 --- a/apps/cli/src/next/commands/logout/logout.command.ts +++ b/apps/cli/src/next/commands/logout/logout.command.ts @@ -6,7 +6,10 @@ import { withCommandInstrumentation } from "../../../shared/telemetry/command-in import { logout } from "./logout.handler.ts"; export const logoutCommand = Command.make("logout", { - yes: Flag.boolean("yes").pipe(Flag.withDescription("Skip the confirmation prompt")), + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Skip the confirmation prompt"), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Log out of Supabase and remove the stored access token."), Command.withShortDescription("Log out of Supabase"), diff --git a/apps/cli/src/next/commands/logs/logs.command.ts b/apps/cli/src/next/commands/logs/logs.command.ts index 4960cc4d57..3fda7df274 100644 --- a/apps/cli/src/next/commands/logs/logs.command.ts +++ b/apps/cli/src/next/commands/logs/logs.command.ts @@ -30,6 +30,7 @@ const flags = { ), noFollow: Flag.boolean("no-follow").pipe( Flag.withDescription("Print buffered history only and exit without following live logs."), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/next/commands/platform/request.command.ts b/apps/cli/src/next/commands/platform/request.command.ts index 1445a523d4..e6f13357b1 100644 --- a/apps/cli/src/next/commands/platform/request.command.ts +++ b/apps/cli/src/next/commands/platform/request.command.ts @@ -53,12 +53,15 @@ const config = { Flag.withDescription( "Show the request and response schema for this route instead of executing it", ), + Flag.withDefault(false), ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription("Validate and preview the outgoing request without executing it"), + Flag.withDefault(false), ), yes: Flag.boolean("yes").pipe( Flag.withDescription("Skip the confirmation prompt for this mutating request"), + Flag.withDefault(false), ), } as const; diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 6d8df52361..c1586e5f7b 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -50,7 +50,10 @@ export const LegacyProfileFlag = GlobalFlag.setting("profile")({ }); export const LegacyDebugFlag = GlobalFlag.setting("debug")({ - flag: Flag.boolean("debug").pipe(Flag.withDescription("output debug logs to stderr")), + flag: Flag.boolean("debug").pipe( + Flag.withDescription("output debug logs to stderr"), + Flag.withDefault(false), + ), }); export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({ @@ -61,7 +64,10 @@ export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({ }); export const LegacyExperimentalFlag = GlobalFlag.setting("experimental")({ - flag: Flag.boolean("experimental").pipe(Flag.withDescription("enable experimental features")), + flag: Flag.boolean("experimental").pipe( + Flag.withDescription("enable experimental features"), + Flag.withDefault(false), + ), }); export const LegacyNetworkIdFlag = GlobalFlag.setting("network-id")({ @@ -72,7 +78,10 @@ export const LegacyNetworkIdFlag = GlobalFlag.setting("network-id")({ }); export const LegacyYesFlag = GlobalFlag.setting("yes")({ - flag: Flag.boolean("yes").pipe(Flag.withDescription("answer yes to all prompts")), + flag: Flag.boolean("yes").pipe( + Flag.withDescription("answer yes to all prompts"), + Flag.withDefault(false), + ), }); export const LegacyDnsResolverFlag = GlobalFlag.setting("dns-resolver")({ @@ -85,6 +94,7 @@ export const LegacyDnsResolverFlag = GlobalFlag.setting("dns-resolver")({ export const LegacyCreateTicketFlag = GlobalFlag.setting("create-ticket")({ flag: Flag.boolean("create-ticket").pipe( Flag.withDescription("create a support ticket for any CLI error"), + Flag.withDefault(false), ), }); 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 35f9586801..0bb1d947c2 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 @@ -1,7 +1,10 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; -import ts from "typescript"; -import { describe, expect, it } from "vitest"; +import * as ts from "typescript/unstable/ast"; +import type { ClassLikeDeclaration, Expression, Node, SourceFile } from "typescript/unstable/ast"; +import { createVirtualFileSystem } from "typescript/unstable/fs"; +import { API } from "typescript/unstable/async"; +import { afterAll, describe, expect, it } from "vitest"; import { CliError } from "effect/unstable/cli"; // Vitest (via Vite) provides `import.meta.glob` at runtime; the workspace @@ -44,7 +47,13 @@ import { // The simple name of a call's callee: `TaggedError` for both `TaggedError(...)` // and `Data.TaggedError(...)`. -function calleeName(expression: ts.Expression): string { +const parserFileSystem = createVirtualFileSystem({}); +const parserApi = new API({ cwd: process.cwd(), fs: parserFileSystem }); +let syntheticFileId = 0; + +afterAll(() => parserApi.close()); + +function calleeName(expression: Expression): string { if (ts.isIdentifier(expression)) return expression.text; if (ts.isPropertyAccessExpression(expression)) return expression.name.text; return ""; @@ -53,27 +62,57 @@ function calleeName(expression: ts.Expression): string { // The value of a plain string literal, seeing through an `as const` assertion // (`readonly code = "X" as const`). A computed or interpolated string cannot be // resolved statically, and none exists in this workspace. -function stringLiteralText(expression: ts.Expression | undefined): string | undefined { +function stringLiteralText(expression: Expression | undefined): string | undefined { const inner = expression !== undefined && ts.isAsExpression(expression) ? expression.expression : expression; return inner !== undefined && ts.isStringLiteral(inner) ? inner.text : undefined; } -function extendsExpression(node: ts.ClassLikeDeclaration): ts.Expression | undefined { +function extendsExpression(node: ClassLikeDeclaration): Expression | undefined { const clause = node.heritageClauses?.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword); return clause?.types[0]?.expression; } -function parse(fileName: string, source: string): ts.SourceFile { - return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); +async function withParsedSources( + sources: ReadonlyArray, + visit: (files: ReadonlyMap) => T, +): Promise { + const normalizedSources = sources.map( + ([fileName, source]) => [resolve(fileName), source] as const, + ); + for (const [fileName, source] of normalizedSources) + parserFileSystem.writeFile?.(fileName, source); + const snapshot = await parserApi.updateSnapshot({ + openFiles: normalizedSources.map(([fileName]) => fileName), + fileChanges: { changed: normalizedSources.map(([fileName]) => fileName) }, + }); + try { + const files = new Map(); + for (const [index, [originalFileName]] of sources.entries()) { + const normalized = normalizedSources[index]; + if (normalized === undefined) throw new Error(`failed to normalize ${originalFileName}`); + const [fileName] = normalized; + const project = await snapshot.getDefaultProjectForFile(fileName); + const sourceFile = await project?.program.getSourceFile(fileName); + if (sourceFile === undefined) throw new Error(`failed to parse ${fileName}`); + files.set(originalFileName, sourceFile); + } + return visit(files); + } finally { + await snapshot.dispose(); + } +} + +async function withParsedSource( + fileName: string, + source: string, + visit: (file: SourceFile) => T, +): Promise { + return withParsedSources([[fileName, source]], (files) => visit(files.get(fileName)!)); } -function hasExportModifier(node: ts.Node): boolean { - return ( - ts.canHaveModifiers(node) && - ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === - true - ); +function hasExportModifier(node: ClassLikeDeclaration): boolean { + return node.modifiers?.some((modifier) => ts.isExportKeyword(modifier)) === true; } // Extracts the error identifiers a source file defines: the tag literal of @@ -82,16 +121,26 @@ function hasExportModifier(node: ts.Node): boolean { // every plain `class X extends Error` (untagged classes are fingerprinted by // name). A tagged class contributes its tag once — the heritage call is // claimed by the class rule so the factory rule does not count it again. -function extractErrorTags( +async function extractErrorTags( source: string, fileName = "scan.ts", options: { readonly exportedOnly?: boolean } = {}, +): Promise> { + const parseFileName = fileName === "scan.ts" ? `scan-${syntheticFileId++}.ts` : fileName; + return withParsedSource(parseFileName, source, (sourceFile) => + extractErrorTagsFromFile(sourceFile, options), + ); +} + +function extractErrorTagsFromFile( + sourceFile: SourceFile, + options: { readonly exportedOnly?: boolean }, ): Array { const tags: Array = []; - const claimed = new Set(); + const claimed = new Set(); - const visit = (node: ts.Node): void => { - if (ts.isClassLike(node)) { + const visit = (node: Node): void => { + if (ts.isClassLikeDeclaration(node)) { const heritage = extendsExpression(node); if (heritage !== undefined && ts.isCallExpression(heritage)) { const tag = calleeName(heritage.expression).endsWith("Error") @@ -120,18 +169,19 @@ function extractErrorTags( if (tag !== undefined) tags.push(tag); } - ts.forEachChild(node, visit); + node.forEachChild(visit); }; - ts.forEachChild(parse(fileName, source), visit); + sourceFile.forEachChild(visit); return tags; } -function scanErrorTags( +async function scanErrorTags( root: string, options: { readonly exportedOnly?: boolean } = {}, -): Map> { +): Promise>> { const tagsByFile = new Map>(); + const sources: Array = []; const walk = (dir: string) => { for (const entry of readdirSync(dir)) { const path = join(dir, entry); @@ -140,11 +190,16 @@ function scanErrorTags( continue; } if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; - const tags = extractErrorTags(readFileSync(path, "utf8"), path, options); - if (tags.length > 0) tagsByFile.set(path, tags); + sources.push([path, readFileSync(path, "utf8")]); } }; walk(root); + await withParsedSources(sources, (files) => { + for (const [fileName, sourceFile] of files) { + const tags = extractErrorTagsFromFile(sourceFile, options); + if (tags.length > 0) tagsByFile.set(fileName, tags); + } + }); return tagsByFile; } @@ -156,7 +211,7 @@ describe("extractErrorTags", () => { "export class PlainThingError extends Error {}", 'const Base = Data.TaggedError("FreeStandingTag");', ].join("\n"); - expect(extractErrorTags(source)).toEqual([ + return expect(extractErrorTags(source)).resolves.toEqual([ "TaggedThingError", "FactoryTag", "PlainThingError", @@ -170,7 +225,7 @@ describe("extractErrorTags", () => { '/* e.g. Data.TaggedError("FakeTag") */', "const x = 1;", ].join("\n"); - expect(extractErrorTags(source)).toEqual([]); + return expect(extractErrorTags(source)).resolves.toEqual([]); }); it("ignores definitions that only appear inside string and template literals", () => { @@ -179,7 +234,7 @@ describe("extractErrorTags", () => { 'const b = `Data.TaggedError("FakeTag")`;', "const c = 'class AlsoFake extends Error';", ].join("\n"); - expect(extractErrorTags(source)).toEqual([]); + return expect(extractErrorTags(source)).resolves.toEqual([]); }); }); @@ -231,9 +286,9 @@ const moduleLoaders = new Map( ]), ); -describe("apps/cli error classes declare their actionability", () => { - const tagsByFile = scanErrorTags(srcRoot); +const tagsByFile = await scanErrorTags(srcRoot); +describe("apps/cli error classes declare their actionability", () => { it("finds the error definition surface", () => { expect(tagsByFile.size).toBeGreaterThan(50); }); @@ -305,8 +360,10 @@ describe("workspace package error tags have external adapters", () => { ]; for (const packageRoot of packageRoots) { - it(packageRoot, () => { - const tagsByFile = scanErrorTags(resolve(repoRoot, packageRoot), { exportedOnly: true }); + it(packageRoot, async () => { + const tagsByFile = await scanErrorTags(resolve(repoRoot, packageRoot), { + exportedOnly: true, + }); expect(tagsByFile.size).toBeGreaterThan(0); for (const [file, tags] of tagsByFile) { for (const tag of tags) { @@ -334,7 +391,7 @@ interface ManagedErrorClass { // Collects the (class, tag, code) triples of every `class X extends // Data.TaggedError("Tag")` that also declares a string-literal `code` member. -function scanManagedErrorClasses(path: string): Array { +async function scanManagedErrorClasses(path: string): Promise> { const classes: Array = []; const visit = (node: ts.Node): void => { if (ts.isClassDeclaration(node) && node.name !== undefined) { @@ -355,16 +412,18 @@ function scanManagedErrorClasses(path: string): Array { classes.push({ className: node.name.text, tag, code }); } } - ts.forEachChild(node, visit); + node.forEachChild(visit); }; - ts.forEachChild(parse(path, readFileSync(path, "utf8")), visit); - return classes; + return withParsedSource(path, readFileSync(path, "utf8"), (sourceFile) => { + sourceFile.forEachChild(visit); + return classes; + }); } describe("managed registry error codes are classified", () => { - it("packages/stack/src/managed/model.ts", () => { + it("packages/stack/src/managed/model.ts", async () => { const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts"); - const scanned = scanManagedErrorClasses(modelPath); + const scanned = await scanManagedErrorClasses(modelPath); // One class per declared code: a class written in a shape this scan cannot // see would otherwise pass vacuously instead of failing loudly. expect(scanned.length).toBe(MANAGED_ERROR_CODES.length); diff --git a/apps/docs/package.json b/apps/docs/package.json index f75048da11..cd850092d0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "bun run generate && next dev", "generate": "bun ../../apps/cli/scripts/generate-docs.ts", - "build": "bun run generate && next build" + "build": "bun run generate && next build", + "types:check": "pnpm exec fumadocs-mdx source.config.ts .source && tsc --noEmit --incremental false" }, "dependencies": { "fumadocs-core": "^16.14.3", @@ -20,7 +21,7 @@ "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "typescript": "^7.0.2" + "typescript": "catalog:" }, "nx": { "implicitDependencies": [ @@ -57,6 +58,26 @@ "{projectRoot}/.next" ] }, + "types:check": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "default", + { + "externalDependencies": [ + "typescript", + "fumadocs-mdx" + ] + } + ], + "options": { + "command": "pnpm run types:check", + "cwd": "{projectRoot}" + }, + "outputs": [ + "{projectRoot}/.source" + ] + }, "dev": { "executor": "nx:run-commands", "options": { diff --git a/docs/nx-inference-plugins.md b/docs/nx-inference-plugins.md index ac58cec640..d243a2e551 100644 --- a/docs/nx-inference-plugins.md +++ b/docs/nx-inference-plugins.md @@ -63,21 +63,21 @@ Infers `lint:check` and `lint:fix` targets for any workspace package that has `o Currently `packages/api` is the only project with `"oxlint": { "typeAware": true }`. -### `tsgo.plugin.ts` +### `typescript.plugin.ts` -**Source:** `tools/nx-plugins/src/tsgo.plugin.ts` +**Source:** `tools/nx-plugins/src/typescript.plugin.ts` -Infers a `types:check` target for any workspace package that has `@typescript/native-preview` in its `devDependencies` (the package that provides the `tsgo` binary). +Infers a `types:check` target for any workspace package that has `typescript` in its `devDependencies`. -**Detection signal:** `package.json` must have `"@typescript/native-preview"` under `devDependencies`. +**Detection signal:** `package.json` must have `"typescript"` under `devDependencies`. -**No per-project config** — the command is always `tsgo --noEmit`. +**No per-project config** — the command is always `tsc --noEmit`. **Inferred targets:** | Target | Command | Cached | Inputs | |--------|---------|--------|--------| -| `types:check` | `tsgo --noEmit` | Yes | `default`, `@typescript/native-preview` package version | +| `types:check` | `tsc --noEmit` | Yes | `default`, `typescript` package version | ## How to discover inferred targets @@ -164,6 +164,6 @@ export const createNodesV2: CreateNodesV2 = [ ## How TypeScript plugins are loaded -Nx loads `.ts` plugin files by registering `@swc-node/register` as a CommonJS transpiler before calling `require()` on the plugin path. This workspace has `@swc-node/register` and `@swc/core` installed at the root, along with a minimal `tsconfig.json` at the workspace root — both are required for Nx to find and activate the transpiler. Without either, Nx falls back to Node.js's native TypeScript type-stripping, which returns a non-extensible ES module namespace that Nx cannot annotate. +With Node 24 and Nx 23.1, Nx loads ESM `.ts` plugins using Node's native TypeScript type-stripping. This is the sole supported plugin loader path. Keep plugins strip-safe (no syntax that requires a transform) and use explicit `.ts` extensions on relative imports. -TypeScript 7 does not yet expose the programmatic compiler API that the Nx transpiler uses. Following the [Nx TypeScript 7 guide](https://nx.dev/docs/technologies/typescript/guides/typescript-7), the root package aliases `typescript` to `@typescript/typescript6` for API consumers and installs TypeScript 7 as `@typescript/native`, which provides the `tsc` executable. +TypeScript 7 is the workspace's sole TypeScript dependency. Inferred type checks invoke its `tsc` executable directly. diff --git a/nx.json b/nx.json index 128d1740b7..47e01b626e 100644 --- a/nx.json +++ b/nx.json @@ -6,7 +6,7 @@ "./tools/nx-plugins/src/knip.plugin.ts", "./tools/nx-plugins/src/oxfmt.plugin.ts", "./tools/nx-plugins/src/oxlint.plugin.ts", - "./tools/nx-plugins/src/tsgo.plugin.ts", + "./tools/nx-plugins/src/typescript.plugin.ts", "./tools/nx-plugins/src/test.plugin.ts", "./tools/nx-plugins/src/go.plugin.ts" ], diff --git a/package.json b/package.json index 0a1d58a865..c4f6354350 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,12 @@ }, "packageManager": "pnpm@11.4.0", "devDependencies": { - "@swc-node/register": "catalog:", - "@swc/core": "catalog:", - "@typescript/native": "npm:typescript@^7.0.2", "nx": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "pkg-pr-new": "0.0.87", - "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript": "catalog:", "verdaccio": "^6.9.2" } } diff --git a/packages/api/package.json b/packages/api/package.json index 963130957e..486bfa5243 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -29,12 +29,12 @@ "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "knip": { @@ -51,7 +51,6 @@ ], "ignoreDependencies": [ "undici", - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint" diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index a16b264eee..24c8a5348b 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -235,12 +235,14 @@ const SCHEMA_METADATA_KEYS = new Set(["default", "example", "examples"]); // a TypeScript schema that omits the property. This bit the SAML SSO // attribute-mapping codegen (each key has `name?`, `names?`, `array?`, and // `default?: any` per OpenAPI spec; the `default?: any` field was silently -// stripped because of this). +// stripped because of this). The union may be encoded as either `oneOf` or +// `anyOf` by different OpenAPI producers. function isArbitraryJsonDefault(schema: OpenApiSchema): boolean { - if (schema.oneOf?.length !== 4) { + const members = schema.oneOf ?? schema.anyOf; + if (members?.length !== 4) { return false; } - const types = new Set(schema.oneOf.map((member) => member.type)); + const types = new Set(members.map((member) => member.type)); return ( types.size === 4 && types.has("object") && @@ -787,7 +789,12 @@ function renderSchemaSource( patterns: "apply", onEnter(schema) { const next = { ...schema }; - if (next.type === "object" && next.additionalProperties === undefined) { + // Bare object schemas stay open; declared-property shapes are closed. + if ( + next.type === "object" && + next.properties !== undefined && + next.additionalProperties === undefined + ) { next.additionalProperties = false; } return next; diff --git a/packages/api/scripts/generate.unit.test.ts b/packages/api/scripts/generate.unit.test.ts index 116f9bf946..3a2c007715 100644 --- a/packages/api/scripts/generate.unit.test.ts +++ b/packages/api/scripts/generate.unit.test.ts @@ -169,7 +169,7 @@ describe("generate", () => { type: "object", properties: { default: { - oneOf: [ + anyOf: [ { type: "object", properties: {} }, { type: "number" }, { type: "string" }, diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index ce0a15a73e..dc54c794a5 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -8,9 +8,11 @@ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import { makeApiClient, operationDefinitions } from "./effect.ts"; import { + V1CreateASsoProviderInput, V1CreateASsoProviderOutput, V1DeleteASsoProviderOutput, V1GetASsoProviderOutput, + V1GetDatabaseOpenapiOutput, V1ListAllSsoProviderOutput, V1UpdateASsoProviderOutput, } from "./generated/contracts.ts"; @@ -57,7 +59,7 @@ const config = { userAgent: "supabase-api/test", } as const; -describe("SSO provider response contracts", () => { +describe("SSO provider contracts", () => { // The provider payload the Management API actually returns: no `saml.id` and // no `domains[].id` — neither field exists in the spec (or in the Go CLI's // `api.ListProvidersResponse`). Every SSO subcommand decodes one of these @@ -112,6 +114,38 @@ describe("SSO provider response contracts", () => { expect(decoded.domains?.[0]).not.toHaveProperty("id"); } }); + + test("accepts object-valued SSO attribute mapping defaults", () => { + expect(() => + Schema.decodeUnknownSync(V1CreateASsoProviderInput)({ + ref: "abcdefghijklmnopqrst", + type: "saml", + attribute_mapping: { + keys: { + role: { default: { department: "engineering" } }, + }, + }, + }), + ).not.toThrow(); + }); +}); + +describe("database OpenAPI response contract", () => { + test("accepts a normal non-empty OpenAPI document", () => { + expect(() => + Schema.decodeUnknownSync(V1GetDatabaseOpenapiOutput)({ + openapi: "3.0.0", + info: { title: "Example", version: "1.0.0" }, + paths: { + "/users": { + get: { + responses: { "200": { description: "ok" } }, + }, + }, + }, + }), + ).not.toThrow(); + }); }); describe("makeApiClient", () => { diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index d649e50f8a..2a41780876 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -342,17 +342,13 @@ export const ThirdPartyAuth = Schema.Struct({ resolved_at: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ identifier: "ThirdPartyAuth" }); // recursive definitions -export type Suspend_ = UpdateCustomHostnameResponseJsonValue; -export const Suspend_ = Schema.suspend( - (): Schema.Codec => UpdateCustomHostnameResponseJsonValue, -); export type UpdateCustomHostnameResponseJsonValue = | string | number | boolean | null - | ReadonlyArray - | { readonly [x: string]: Suspend_ }; + | ReadonlyArray + | { readonly [x: string]: UpdateCustomHostnameResponseJsonValue }; export const UpdateCustomHostnameResponseJsonValue = Schema.Union([ Schema.Union([ Schema.Union([ @@ -362,26 +358,30 @@ export const UpdateCustomHostnameResponseJsonValue = Schema.Union([ ]), Schema.Null, ]), - Schema.Array(Schema.suspend((): Schema.Codec => Suspend_)), + Schema.Array( + Schema.suspend( + (): Schema.Codec => + UpdateCustomHostnameResponseJsonValue, + ), + ), Schema.Record( Schema.String, - Schema.suspend((): Schema.Codec => Suspend_), + Schema.suspend( + (): Schema.Codec => + UpdateCustomHostnameResponseJsonValue, + ), ), ]).annotate({ description: "Any JSON-serializable value", identifier: "UpdateCustomHostnameResponseJsonValue", }); -export type Suspend_1 = ListProjectAddonsResponseJsonValue; -export const Suspend_1 = Schema.suspend( - (): Schema.Codec => ListProjectAddonsResponseJsonValue, -); export type ListProjectAddonsResponseJsonValue = | string | number | boolean | null - | ReadonlyArray - | { readonly [x: string]: Suspend_1 }; + | ReadonlyArray + | { readonly [x: string]: ListProjectAddonsResponseJsonValue }; export const ListProjectAddonsResponseJsonValue = Schema.Union([ Schema.Union([ Schema.Union([ @@ -391,10 +391,16 @@ export const ListProjectAddonsResponseJsonValue = Schema.Union([ ]), Schema.Null, ]), - Schema.Array(Schema.suspend((): Schema.Codec => Suspend_1)), + Schema.Array( + Schema.suspend( + (): Schema.Codec => ListProjectAddonsResponseJsonValue, + ), + ), Schema.Record( Schema.String, - Schema.suspend((): Schema.Codec => Suspend_1), + Schema.suspend( + (): Schema.Codec => ListProjectAddonsResponseJsonValue, + ), ), ]).annotate({ description: "Any JSON-serializable value", @@ -1334,14 +1340,7 @@ export const V1CreateASsoProviderInput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), @@ -1371,16 +1370,7 @@ export const V1CreateASsoProviderOutput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check( - Schema.isFinite().annotate({ expected: "a finite number" }), - ), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), @@ -1918,16 +1908,7 @@ export const V1DeleteASsoProviderOutput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check( - Schema.isFinite().annotate({ expected: "a finite number" }), - ), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), @@ -2572,7 +2553,7 @@ export const V1GetAFunctionBodyInput = Schema.Struct({ }), ), }); -export const V1GetAFunctionBodyOutput = Schema.Struct({}); +export const V1GetAFunctionBodyOutput = Schema.Record(Schema.String, Schema.Never); export const V1GetAMigrationInput = Schema.Struct({ ref: Schema.String.check( Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), @@ -2678,16 +2659,7 @@ export const V1GetASsoProviderOutput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check( - Schema.isFinite().annotate({ expected: "a finite number" }), - ), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), @@ -3730,7 +3702,10 @@ export const V1GetDatabaseOpenapiInput = Schema.Struct({ ), schema: Schema.optionalKey(Schema.String), }); -export const V1GetDatabaseOpenapiOutput = Schema.Struct({}); +export const V1GetDatabaseOpenapiOutput = Schema.Record( + Schema.String, + Schema.Json.annotate({ expected: "JSON value" }), +); export const V1GetDiskUtilizationInput = Schema.Struct({ ref: Schema.String.check( Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), @@ -4611,7 +4586,7 @@ export const V1GetPostgrestServiceConfigOutput = Schema.Struct({ ]), jwt_secret: Schema.optionalKey(Schema.String), }); -export const V1GetProfileInput = Schema.Struct({}); +export const V1GetProfileInput = Schema.Record(Schema.String, Schema.Never); export const V1GetProfileOutput = Schema.Struct({ gotrue_id: Schema.String, primary_email: Schema.String, @@ -6020,9 +5995,9 @@ export const V1ListAllNetworkBansEnrichedOutput = Schema.Struct({ }), ), }); -export const V1ListAllOrganizationsInput = Schema.Struct({}); +export const V1ListAllOrganizationsInput = Schema.Record(Schema.String, Schema.Never); export const V1ListAllOrganizationsOutput = Schema.Array(OrganizationResponseV1); -export const V1ListAllProjectsInput = Schema.Struct({}); +export const V1ListAllProjectsInput = Schema.Record(Schema.String, Schema.Never); export const V1ListAllProjectsOutput = Schema.Array(V1ProjectWithDatabaseResponse); export const V1ListAllSecretsInput = Schema.Struct({ ref: Schema.String.check( @@ -6108,16 +6083,7 @@ export const V1ListAllSsoProviderOutput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check( - Schema.isFinite().annotate({ expected: "a finite number" }), - ), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), @@ -7277,14 +7243,7 @@ export const V1UpdateASsoProviderInput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), @@ -7314,16 +7273,7 @@ export const V1UpdateASsoProviderOutput = Schema.Struct({ Schema.Struct({ name: Schema.optionalKey(Schema.String), names: Schema.optionalKey(Schema.Array(Schema.String)), - default: Schema.optionalKey( - Schema.Union([ - Schema.Struct({}), - Schema.Number.check( - Schema.isFinite().annotate({ expected: "a finite number" }), - ), - Schema.String, - Schema.Boolean, - ]), - ), + default: Schema.optionalKey(Schema.Json.annotate({ expected: "JSON value" })), array: Schema.optionalKey(Schema.Boolean), }), ), diff --git a/packages/cli-test-helpers/package.json b/packages/cli-test-helpers/package.json index a65947dafa..a824dd7683 100644 --- a/packages/cli-test-helpers/package.json +++ b/packages/cli-test-helpers/package.json @@ -14,12 +14,12 @@ "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "knip": { @@ -27,7 +27,6 @@ "src/**/*.test.ts" ], "ignoreDependencies": [ - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint" diff --git a/packages/config/package.json b/packages/config/package.json index 20142f7605..2e414c83cc 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -26,17 +26,16 @@ "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "knip": { "ignoreDependencies": [ - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint" diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 0bf4ee849a..0159b251d4 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -926,7 +926,6 @@ major_version = 16 const schema = toProjectConfigJsonSchema(); const schemaString = JSON.stringify(schema); - expect(schema).toHaveProperty("$defs"); expect(schemaString).toContain("local_smtp"); expect(schemaString).toContain("remotes"); expect(schemaString).toContain("static_files"); diff --git a/packages/config/src/lib/env.unit.test.ts b/packages/config/src/lib/env.unit.test.ts index 70890599d9..3298947324 100644 --- a/packages/config/src/lib/env.unit.test.ts +++ b/packages/config/src/lib/env.unit.test.ts @@ -8,7 +8,7 @@ describe("env()", () => { const normalized = JSON.parse(JSON.stringify(json)); expect(normalized.type).toBe("string"); - expect(normalized.allOf?.[0]?.pattern).toBe(ENV_PATTERN); + expect(normalized.pattern).toBe(ENV_PATTERN); }); test("does not fail when secret metadata is omitted", () => { @@ -22,6 +22,6 @@ describe("env()", () => { const json = Schema.toJsonSchemaDocument(env({ secret: true })).schema; const normalized = JSON.parse(JSON.stringify(json)); - expect(normalized.allOf?.[0]?.pattern).toBe(ENV_PATTERN); + expect(normalized.pattern).toBe(ENV_PATTERN); }); }); diff --git a/packages/process-compose/package.json b/packages/process-compose/package.json index 9e9dd92cc9..c192fa8190 100644 --- a/packages/process-compose/package.json +++ b/packages/process-compose/package.json @@ -20,12 +20,12 @@ "@effect/vitest": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "knip": { @@ -34,7 +34,6 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint" diff --git a/packages/stack/package.json b/packages/stack/package.json index cf347b8d27..426561d985 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -39,12 +39,12 @@ "@supabase/supabase-js": "^2.112.3", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "knip": { @@ -55,7 +55,6 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8a962b916..6312ce1a56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,44 +7,35 @@ settings: catalogs: default: '@effect/atom-react': - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 '@effect/platform-bun': - specifier: 4.0.0-rc.108 - version: 4.0.0-rc.108 + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 '@effect/platform-node': - specifier: 4.0.0-rc.108 - version: 4.0.0-rc.108 + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 '@effect/sql-pg': - specifier: 4.0.0-rc.108 - version: 4.0.0-rc.108 + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 '@effect/vitest': - specifier: 4.0.0-rc.108 - version: 4.0.0-rc.108 + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 '@nx/devkit': specifier: ^23.1.1 version: 23.1.1 - '@swc-node/register': - specifier: ^1.12.1 - version: 1.12.1 - '@swc/core': - specifier: ^1.15.47 - version: 1.15.47 '@tsconfig/bun': specifier: ^1.0.10 version: 1.0.10 '@types/bun': specifier: ^1.3.14 version: 1.3.14 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260707.2 - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: ^4.1.10 version: 4.1.10 effect: - specifier: 4.0.0-rc.108 - version: 4.0.0-rc.108 + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 knip: specifier: ^6.32.2 version: 6.32.2 @@ -63,12 +54,14 @@ catalogs: tldts: specifier: ^7.4.10 version: 7.4.10 + typescript: + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10 overrides: - '@effect/platform-node-shared': 4.0.0-beta.107 '@launchql/protobufjs>@types/node': 24.10.4 patchedDependencies: @@ -78,18 +71,9 @@ importers: .: devDependencies: - '@swc-node/register': - specifier: 'catalog:' - version: 1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2) - '@swc/core': - specifier: 'catalog:' - version: 1.15.47 - '@typescript/native': - specifier: npm:typescript@^7.0.2 - version: typescript@7.0.2 nx: specifier: 'catalog:' - version: 23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.47) + version: 23.1.1 oxfmt: specifier: 'catalog:' version: 0.63.0 @@ -103,8 +87,8 @@ importers: specifier: 0.0.87 version: 0.0.87 typescript: - specifier: npm:@typescript/typescript6@^6.0.2 - version: '@typescript/typescript6@6.0.2' + specifier: 'catalog:' + version: 7.0.2 verdaccio: specifier: ^6.9.2 version: 6.9.2(typanion@3.14.0) @@ -129,16 +113,16 @@ importers: version: 1.7.0 '@effect/atom-react': specifier: 'catalog:' - version: 4.0.0-beta.107(effect@4.0.0-rc.108)(react@19.2.8)(scheduler@0.27.0) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(react@19.2.8)(scheduler@0.27.0) '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108) + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/sql-pg': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108) + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) '@modelcontextprotocol/sdk': specifier: ^1.30.0 version: 1.30.0(zod@4.4.3) @@ -181,9 +165,6 @@ importers: '@types/react': specifier: ^19.2.18 version: 19.2.18 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vercel/detect-agent': specifier: ^1.2.5 version: 1.2.5 @@ -195,7 +176,7 @@ importers: version: 17.4.2 effect: specifier: 'catalog:' - version: 4.0.0-rc.108 + version: 4.0.0-rc.111 esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -234,7 +215,7 @@ importers: version: 7.0.1 semantic-release: specifier: ^25.0.9 - version: 25.0.9(@typescript/typescript6@6.0.2) + version: 25.0.9(typescript@7.0.2) smol-toml: specifier: ^1.8.0 version: 1.8.0 @@ -242,8 +223,8 @@ importers: specifier: 'catalog:' version: 7.4.10 typescript: - specifier: npm:@typescript/typescript6@^6.0.2 - version: '@typescript/typescript6@6.0.2' + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -288,9 +269,6 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.3.14 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -306,6 +284,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -344,20 +325,20 @@ importers: specifier: ^19.2.4 version: 19.2.4(@types/react@19.2.18) typescript: - specifier: ^7.0.2 + specifier: 'catalog:' version: 7.0.2 packages/api: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108) + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) effect: specifier: 'catalog:' - version: 4.0.0-rc.108 + version: 4.0.0-rc.111 undici: specifier: ^8.10.0 version: 8.10.0 @@ -368,9 +349,6 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.3.14 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -386,6 +364,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -410,9 +391,6 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.3.14 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -428,6 +406,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -440,16 +421,16 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108) + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) dedent: specifier: ^1.7.2 version: 1.7.2 effect: specifier: 'catalog:' - version: 4.0.0-rc.108 + version: 4.0.0-rc.111 smol-toml: specifier: ^1.8.0 version: 1.8.0 @@ -460,9 +441,6 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.3.14 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -478,6 +456,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -486,23 +467,20 @@ importers: dependencies: effect: specifier: 'catalog:' - version: 4.0.0-rc.108 + version: 4.0.0-rc.111 devDependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108) + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 '@types/bun': specifier: 'catalog:' version: 1.3.14 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -518,6 +496,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -526,20 +507,20 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108) + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) '@supabase/process-compose': specifier: workspace:* version: link:../process-compose effect: specifier: 'catalog:' - version: 4.0.0-rc.108 + version: 4.0.0-rc.111 devDependencies: '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) '@supabase/supabase-js': specifier: ^2.112.3 version: 2.112.3 @@ -549,9 +530,6 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.3.14 - '@typescript/native-preview': - specifier: 'catalog:' - version: 7.0.0-dev.20260707.2 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -567,6 +545,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -575,10 +556,10 @@ importers: dependencies: '@nx/devkit': specifier: 'catalog:' - version: 23.1.1(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.47)) + version: 23.1.1(nx@23.1.1) typescript: - specifier: npm:@typescript/typescript6@^6.0.2 - version: '@typescript/typescript6@6.0.2' + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -763,40 +744,40 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@effect/atom-react@4.0.0-beta.107': - resolution: {integrity: sha512-dSx8Mmgge8oy3On8k95V3dyaRo9od8Eg0our56PIrTlRZBBRLXM2vc/X7GsxAYtHs2PMracVFUJqizXCcPsCyQ==} + '@effect/atom-react@4.0.0-rc.111': + resolution: {integrity: sha512-MQgX2+ayms/J686g19vV2AkLSocMaubZmFf2VFrWN3fwPg60XXsaPtlQKdcsIy/McxVGZYgYUPXBVVtcmUjjSA==} peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.111 react: '>=19.2.7 <20.0.0' scheduler: '>=0.27.0 <0.28.0' - '@effect/platform-bun@4.0.0-rc.108': - resolution: {integrity: sha512-27RoALzmzx6Qp4LrPIE8bYJfHe+8ZaAO3xLhJMEE6mVA8fxcPh4HAGwBv09Nk2qTFVh578SlSCY5ojUlqeSJ4A==} + '@effect/platform-bun@4.0.0-rc.111': + resolution: {integrity: sha512-z6MF1ztw8oSvh4nlvI90wraB63ib5K67JPaSYtz5K9IIAgjXgXYC0hjbcqF+PlC9ytlQmu8tWzk2+yGePWWe5A==} peerDependencies: - effect: ^4.0.0-rc.108 + effect: ^4.0.0-rc.111 - '@effect/platform-node-shared@4.0.0-beta.107': - resolution: {integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==} + '@effect/platform-node-shared@4.0.0-rc.111': + resolution: {integrity: sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.111 - '@effect/platform-node@4.0.0-rc.108': - resolution: {integrity: sha512-Nof78154BaHGdSYr4TPQFZ5+Dg+HkpmbI3SQUdwsby5QNs6yahGJPu2AgdIVqdx7pKZ2w7j/bvdnqoMmkG0PbA==} + '@effect/platform-node@4.0.0-rc.111': + resolution: {integrity: sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-rc.108 - ioredis: '>=5.7.0 <6.0.0' + effect: ^4.0.0-rc.111 + redis: '>=5.0.0 <7.0.0' - '@effect/sql-pg@4.0.0-rc.108': - resolution: {integrity: sha512-K7PZL+J71IDRsOu4CHtIJUCoDpsfD1G9Mdttn/jj9tCsQFA9ySIu2+IFmUlgJAvUUlfdg/OMhkocxkGoVZJTLw==} + '@effect/sql-pg@4.0.0-rc.111': + resolution: {integrity: sha512-QjYFyN5cUlJGJWzXA8GHh7BJydc2K8ImIFvG3kf8f/Nv3w6ZuycuRKIW3TUNtOmHtVvqr/OugPFtyvHYhkDEbg==} peerDependencies: - effect: ^4.0.0-rc.108 + effect: ^4.0.0-rc.111 - '@effect/vitest@4.0.0-rc.108': - resolution: {integrity: sha512-XD2GP1JATN28wnIeFGsBqYMuQDCHIodKKgFulGyG91GvuHqgf2gz+WxR2LPdyNoKMtr7lsbRzVj07niSYtIKzA==} + '@effect/vitest@4.0.0-rc.111': + resolution: {integrity: sha512-YDaEVT+grREBVMzykRNFtJwGxy02achzT0WXZYwMJA4ukzuB9krkgQ5roc3N6zhC3NQq/j4IyM32/Pcov7AhHw==} peerDependencies: - effect: ^4.0.0-rc.108 + effect: ^4.0.0-rc.111 vitest: '>=4.1.0 <5.0.0' '@emnapi/core@1.11.1': @@ -805,9 +786,6 @@ packages: '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} - '@emnapi/core@1.11.3': - resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} - '@emnapi/core@1.4.5': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} @@ -829,9 +807,6 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@emnapi/wasi-threads@1.2.3': - resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1189,9 +1164,6 @@ packages: cpu: [x64] os: [win32] - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@istanbuljs/schema@0.1.6': resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} @@ -1350,9 +1322,6 @@ packages: resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} @@ -1437,97 +1406,6 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@node-rs/xxhash-android-arm-eabi@1.7.6': - resolution: {integrity: sha512-ptmfpFZ8SgTef58Us+0HsZ9BKhyX/gZYbhLkuzPt7qUoMqMSJK85NC7LEgzDgjUiG+S5GahEEQ9/tfh9BVvKhw==} - engines: {node: '>= 12'} - cpu: [arm] - os: [android] - - '@node-rs/xxhash-android-arm64@1.7.6': - resolution: {integrity: sha512-n4MyZvqifuoARfBvrZ2IBqmsGzwlVI3kb2mB0gVvoHtMsPbl/q94zoDBZ7WgeP3t4Wtli+QS3zgeTCOWUbqqUQ==} - engines: {node: '>= 12'} - cpu: [arm64] - os: [android] - - '@node-rs/xxhash-darwin-arm64@1.7.6': - resolution: {integrity: sha512-6xGuE07CiCIry/KT3IiwQd/kykTOmjKzO/ZnHlE5ibGMx64NFE0qDuwJbxQ4rGyUzgJ0KuN9ZdOhUDJmepnpcw==} - engines: {node: '>= 12'} - cpu: [arm64] - os: [darwin] - - '@node-rs/xxhash-darwin-x64@1.7.6': - resolution: {integrity: sha512-Z4oNnhyznDvHhxv+s0ka+5KG8mdfLVucZMZMejj9BL+CPmamClygPiHIRiifRcPAoX9uPZykaCsULngIfLeF3Q==} - engines: {node: '>= 12'} - cpu: [x64] - os: [darwin] - - '@node-rs/xxhash-freebsd-x64@1.7.6': - resolution: {integrity: sha512-arCDOf3xZ5NfBL5fk5J52sNPjXL2cVWN6nXNB3nrtRFFdPBLsr6YXtshAc6wMVxnIW4VGaEv/5K6IpTA8AFyWw==} - engines: {node: '>= 12'} - cpu: [x64] - os: [freebsd] - - '@node-rs/xxhash-linux-arm-gnueabihf@1.7.6': - resolution: {integrity: sha512-ndLLEW+MwLH3lFS0ahlHCcmkf2ykOv/pbP8OBBeAOlz/Xc3jKztg5IJ9HpkjKOkHk470yYxgHVaw1QMoMzU00A==} - engines: {node: '>= 12'} - cpu: [arm] - os: [linux] - - '@node-rs/xxhash-linux-arm64-gnu@1.7.6': - resolution: {integrity: sha512-VX7VkTG87mAdrF2vw4aroiRpFIIN8Lj6NgtGHF+IUVbzQxPudl4kG+FPEjsNH8y04yQxRbPE7naQNgHcTKMrNw==} - engines: {node: '>= 12'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@node-rs/xxhash-linux-arm64-musl@1.7.6': - resolution: {integrity: sha512-AB5m6crGYSllM9F/xZNOQSPImotR5lOa9e4arW99Bv82S+gcpphI8fGMDOVTTCXY/RLRhvvhwzLDxmLB2O8VDg==} - engines: {node: '>= 12'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@node-rs/xxhash-linux-x64-gnu@1.7.6': - resolution: {integrity: sha512-a2A6M+5tc0PVlJlE/nl0XsLEzMpKkwg7Y1lR5urFUbW9uVQnKjJYQDrUojhlXk0Uv3VnYQPa6ThmwlacZA5mvQ==} - engines: {node: '>= 12'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@node-rs/xxhash-linux-x64-musl@1.7.6': - resolution: {integrity: sha512-WioGJSC1GoxQpmdQrG5l/uddSBAS4XCWczHNwXe895J5xadGQzyvmr0r17BNfihvbBUDH1H9jwouNYzDDeA6+A==} - engines: {node: '>= 12'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@node-rs/xxhash-wasm32-wasi@1.7.6': - resolution: {integrity: sha512-WDXXKMMFMrez+esm2DzMPHFNPFYf+wQUtaXrXwtxXeQMFEzleOLwEaqV0+bbXGJTwhPouL3zY1Qo2xmIH4kkTg==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@node-rs/xxhash-win32-arm64-msvc@1.7.6': - resolution: {integrity: sha512-qjDFUZJT/Zq0yFS+0TApkD86p0NBdPXlOoHur9yNeO9YX2/9/b1sC2P7N27PgOu13h61TUOvTUC00e/82jAZRQ==} - engines: {node: '>= 12'} - cpu: [arm64] - os: [win32] - - '@node-rs/xxhash-win32-ia32-msvc@1.7.6': - resolution: {integrity: sha512-s7a+mQWOTnU4NiiypRq/vbNGot/il0HheXuy9oxJ0SW2q/e4BJ8j0pnP6UBlAjsk+005A76vOwsEj01qbQw8+A==} - engines: {node: '>= 12'} - cpu: [ia32] - os: [win32] - - '@node-rs/xxhash-win32-x64-msvc@1.7.6': - resolution: {integrity: sha512-zHOHm2UaIahRhgRPJll+4Xy4Z18aAT/7KNeQW+QJupGvFz+GzOFXMGs3R/3B1Ktob/F5ui3i1MrW9GEob3CWTg==} - engines: {node: '>= 12'} - cpu: [x64] - os: [win32] - - '@node-rs/xxhash@1.7.6': - resolution: {integrity: sha512-XMisO+aQHsVpxRp/85EszTtOQTOlhPbd149P/Xa9F55wafA6UM3h2UhOgOs7aAzItnHU/Aw1WQ1FVTEg7WB43Q==} - engines: {node: '>= 12'} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2656,6 +2534,42 @@ packages: '@radix-ui/rect@1.1.3': resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@redis/bloom@6.2.1': + resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/client@6.2.1': + resolution: {integrity: sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.2.1': + resolution: {integrity: sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/search@6.2.1': + resolution: {integrity: sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/time-series@6.2.1': + resolution: {integrity: sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2883,118 +2797,9 @@ packages: '@opentelemetry/api': optional: true - '@swc-node/core@1.15.0': - resolution: {integrity: sha512-WAerrrl087WgenB92XG4Th2t0NQFfMNLYSe0sW2cEMMqM/LQmP4rozsDJl0vuzTrbewjpKQryxFXI+aYig/dBg==} - engines: {node: '>= 10'} - peerDependencies: - '@swc/core': '>= 1.13.3' - '@swc/types': '>= 0.1' - - '@swc-node/register@1.12.1': - resolution: {integrity: sha512-t6t+0bDos+bj0+jcSqKl7+ys/i1an5cEViC0LuIskJFSHONX871nXN8j+gxdKVOgCCpjs0buXUPNnp/0DaV4EQ==} - peerDependencies: - '@swc/core': '>= 1.4.13' - typescript: '>= 4.3 < 7' - - '@swc-node/sourcemap-support@0.6.1': - resolution: {integrity: sha512-ovltDVH5QpdHXZkW138vG4+dgcNsxfwxHVoV6BtmTbz2KKl1A8ZSlbdtxzzfNjCjbpayda8Us9eMtcHobm38dA==} - - '@swc/core-darwin-arm64@1.15.47': - resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-x64@1.15.47': - resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-linux-arm-gnueabihf@1.15.47': - resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.15.47': - resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-arm64-musl@1.15.47': - resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@swc/core-linux-ppc64-gnu@1.15.47': - resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} - engines: {node: '>=10'} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-s390x-gnu@1.15.47': - resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} - engines: {node: '>=10'} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@swc/core-linux-x64-gnu@1.15.47': - resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-x64-musl@1.15.47': - resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@swc/core-win32-arm64-msvc@1.15.47': - resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] - - '@swc/core-win32-ia32-msvc@1.15.47': - resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} - engines: {node: '>=10'} - cpu: [ia32] - os: [win32] - - '@swc/core-win32-x64-msvc@1.15.47': - resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] - - '@swc/core@1.15.47': - resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} - engines: {node: '>=10'} - peerDependencies: - '@swc/helpers': '>=0.5.17' - peerDependenciesMeta: - '@swc/helpers': - optional: true - - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/types@0.1.27': - resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} - '@tsconfig/bun@1.0.10': resolution: {integrity: sha512-5AV5YknQjNyoYzZ/8NG0dawqew/wH+x7ANiCfCIn29qo0cdbd1EryvFD1k5NSZWLBMOI/fGqMIaxi58GPIP9Cg==} @@ -3072,53 +2877,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - - '@typescript/native-preview@7.0.0-dev.20260707.2': - resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} - engines: {node: '>=16.20.0'} - hasBin: true - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -3239,10 +2997,6 @@ packages: cpu: [x64] os: [win32] - '@typescript/typescript6@6.0.2': - resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} - hasBin: true - '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -3851,8 +3605,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cluster-key-slot@1.1.1: - resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} cnfast@0.1.0: @@ -4058,10 +3812,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4127,8 +3877,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@4.0.0-rc.108: - resolution: {integrity: sha512-KmI3DlKZWPvCL4QQ2FMaPOuxMt/7DrKMENCY/gQ+MkDR5QYw25wgU5Zmh/wVLboNjIci1gNOgNCFe4xqgxli3A==} + effect@4.0.0-rc.111: + resolution: {integrity: sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==} ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} @@ -4331,9 +4081,6 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} @@ -4850,10 +4597,6 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} - ip-address@10.3.1: resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} engines: {node: '>= 12'} @@ -5077,9 +4820,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - kubernetes-types@1.30.0: - resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -5564,8 +5304,8 @@ packages: resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true - msgpackr@2.0.4: - resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -5973,8 +5713,8 @@ packages: pg-copy-streams@7.0.0: resolution: {integrity: sha512-zBvnY6wtaBRE2ae2xXWOOGMaNVPkXh1vhypAkNSKgMdciJeTyIQAHZaEeRAxUjs/p1El5jgzYmwG5u871Zj3dQ==} - pg-cursor@2.21.0: - resolution: {integrity: sha512-IYvk/j+Suhtbo/C3uOf4JLsLK/gWxOTUOmYbDsbKnLaVJDq+KwhwK6ngpRfiCk8eDMS3AmGQABZCv0cREEzHQw==} + pg-cursor@2.22.0: + resolution: {integrity: sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==} peerDependencies: pg: ^8 @@ -6048,10 +5788,6 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -6302,13 +6038,9 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} + redis@6.2.1: + resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} + engines: {node: '>= 20.0.0'} regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -6557,9 +6289,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -6605,9 +6334,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -6882,11 +6608,6 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -7021,10 +6742,6 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@14.0.1: - resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} - hasBin: true - validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -7226,6 +6943,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -7521,54 +7250,54 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@effect/atom-react@4.0.0-beta.107(effect@4.0.0-rc.108)(react@19.2.8)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-rc.111(effect@4.0.0-rc.111)(react@19.2.8)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-rc.108 + effect: 4.0.0-rc.111 react: 19.2.8 scheduler: 0.27.0 - '@effect/platform-bun@4.0.0-rc.108(effect@4.0.0-rc.108)': + '@effect/platform-bun@4.0.0-rc.111(effect@4.0.0-rc.111)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-rc.108) - effect: 4.0.0-rc.108 + '@effect/platform-node-shared': 4.0.0-rc.111(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-rc.108)': + '@effect/platform-node-shared@4.0.0-rc.111(effect@4.0.0-rc.111)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-rc.108 - ws: 8.21.1 + effect: 4.0.0-rc.111 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)': + '@effect/platform-node@4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-rc.108) - effect: 4.0.0-rc.108 - ioredis: 5.11.1 + '@effect/platform-node-shared': 4.0.0-rc.111(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 mime: 4.1.0 + redis: 6.2.1 undici: 8.10.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-rc.108(effect@4.0.0-rc.108)': + '@effect/sql-pg@4.0.0-rc.111(effect@4.0.0-rc.111)': dependencies: - effect: 4.0.0-rc.108 + effect: 4.0.0-rc.111 pg: 8.23.0 pg-connection-string: 2.14.0 - pg-cursor: 2.21.0(pg@8.23.0) + pg-cursor: 2.22.0(pg@8.23.0) pg-pool: 3.14.0(pg@8.23.0) pg-types: 4.1.0 transitivePeerDependencies: - pg-native - '@effect/vitest@4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10)': + '@effect/vitest@4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10)': dependencies: - effect: 4.0.0-rc.108 + effect: 4.0.0-rc.111 vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@emnapi/core@1.11.1': @@ -7583,12 +7312,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.11.3': - dependencies: - '@emnapi/wasi-threads': 1.2.3 - tslib: 2.8.1 - optional: true - '@emnapi/core@1.4.5': dependencies: '@emnapi/wasi-threads': 1.0.4 @@ -7622,11 +7345,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.3': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true @@ -7842,8 +7560,6 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true - '@ioredis/commands@1.10.0': {} - '@istanbuljs/schema@0.1.6': {} '@jest/diff-sequences@30.0.1': {} @@ -8010,13 +7726,6 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 - '@napi-rs/wasm-runtime@0.2.12': - dependencies: - '@emnapi/core': 1.11.3 - '@emnapi/runtime': 1.11.3 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@0.2.4': dependencies: '@emnapi/core': 1.4.5 @@ -8071,67 +7780,6 @@ snapshots: '@noble/hashes@1.8.0': {} - '@node-rs/xxhash-android-arm-eabi@1.7.6': - optional: true - - '@node-rs/xxhash-android-arm64@1.7.6': - optional: true - - '@node-rs/xxhash-darwin-arm64@1.7.6': - optional: true - - '@node-rs/xxhash-darwin-x64@1.7.6': - optional: true - - '@node-rs/xxhash-freebsd-x64@1.7.6': - optional: true - - '@node-rs/xxhash-linux-arm-gnueabihf@1.7.6': - optional: true - - '@node-rs/xxhash-linux-arm64-gnu@1.7.6': - optional: true - - '@node-rs/xxhash-linux-arm64-musl@1.7.6': - optional: true - - '@node-rs/xxhash-linux-x64-gnu@1.7.6': - optional: true - - '@node-rs/xxhash-linux-x64-musl@1.7.6': - optional: true - - '@node-rs/xxhash-wasm32-wasi@1.7.6': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@node-rs/xxhash-win32-arm64-msvc@1.7.6': - optional: true - - '@node-rs/xxhash-win32-ia32-msvc@1.7.6': - optional: true - - '@node-rs/xxhash-win32-x64-msvc@1.7.6': - optional: true - - '@node-rs/xxhash@1.7.6': - optionalDependencies: - '@node-rs/xxhash-android-arm-eabi': 1.7.6 - '@node-rs/xxhash-android-arm64': 1.7.6 - '@node-rs/xxhash-darwin-arm64': 1.7.6 - '@node-rs/xxhash-darwin-x64': 1.7.6 - '@node-rs/xxhash-freebsd-x64': 1.7.6 - '@node-rs/xxhash-linux-arm-gnueabihf': 1.7.6 - '@node-rs/xxhash-linux-arm64-gnu': 1.7.6 - '@node-rs/xxhash-linux-arm64-musl': 1.7.6 - '@node-rs/xxhash-linux-x64-gnu': 1.7.6 - '@node-rs/xxhash-linux-x64-musl': 1.7.6 - '@node-rs/xxhash-wasm32-wasi': 1.7.6 - '@node-rs/xxhash-win32-arm64-msvc': 1.7.6 - '@node-rs/xxhash-win32-ia32-msvc': 1.7.6 - '@node-rs/xxhash-win32-x64-msvc': 1.7.6 - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8144,12 +7792,12 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nx/devkit@23.1.1(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.47))': + '@nx/devkit@23.1.1(nx@23.1.1)': dependencies: ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.5 - nx: 23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.47) + nx: 23.1.1 semver: 7.8.5 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -8964,6 +8612,26 @@ snapshots: '@radix-ui/rect@1.1.3': {} + '@redis/bloom@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/client@6.2.1': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/search@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/time-series@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -9017,7 +8685,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(@typescript/typescript6@6.0.2))': + '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(typescript@7.0.2))': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 8.4.0 @@ -9027,13 +8695,13 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.18.1 micromatch: 4.0.8 - semantic-release: 25.0.9(@typescript/typescript6@6.0.2) + semantic-release: 25.0.9(typescript@7.0.2) transitivePeerDependencies: - supports-color '@semantic-release/error@4.0.0': {} - '@semantic-release/github@12.0.9(semantic-release@25.0.9(@typescript/typescript6@6.0.2))': + '@semantic-release/github@12.0.9(semantic-release@25.0.9(typescript@7.0.2))': dependencies: '@octokit/core': 7.0.7 '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.7) @@ -9049,7 +8717,7 @@ snapshots: lodash-es: 4.18.1 mime: 4.1.0 p-filter: 4.1.0 - semantic-release: 25.0.9(@typescript/typescript6@6.0.2) + semantic-release: 25.0.9(typescript@7.0.2) tinyglobby: 0.2.17 undici: 7.29.0 url-join: 5.0.0 @@ -9057,7 +8725,7 @@ snapshots: - kerberos - supports-color - '@semantic-release/npm@13.1.5(semantic-release@25.0.9(@typescript/typescript6@6.0.2))': + '@semantic-release/npm@13.1.5(semantic-release@25.0.9(typescript@7.0.2))': dependencies: '@actions/core': 3.0.1 '@semantic-release/error': 4.0.0 @@ -9072,11 +8740,11 @@ snapshots: rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 - semantic-release: 25.0.9(@typescript/typescript6@6.0.2) + semantic-release: 25.0.9(typescript@7.0.2) semver: 7.8.5 tempy: 3.2.0 - '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(@typescript/typescript6@6.0.2))': + '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(typescript@7.0.2))': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 8.4.0 @@ -9086,7 +8754,7 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.18.1 read-package-up: 11.0.0 - semantic-release: 25.0.9(@typescript/typescript6@6.0.2) + semantic-release: 25.0.9(typescript@7.0.2) transitivePeerDependencies: - supports-color @@ -9192,97 +8860,10 @@ snapshots: '@supabase/realtime-js': 2.112.3 '@supabase/storage-js': 2.112.3 - '@swc-node/core@1.15.0(@swc/core@1.15.47)(@swc/types@0.1.27)': - dependencies: - '@swc/core': 1.15.47 - '@swc/types': 0.1.27 - - '@swc-node/register@1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2)': - dependencies: - '@node-rs/xxhash': 1.7.6 - '@swc-node/core': 1.15.0(@swc/core@1.15.47)(@swc/types@0.1.27) - '@swc-node/sourcemap-support': 0.6.1 - '@swc/core': 1.15.47 - colorette: 2.0.20 - debug: 4.4.3(supports-color@7.2.0) - fast-json-stable-stringify: 2.1.0 - oxc-resolver: 11.24.2 - pirates: 4.0.7 - tslib: 2.8.1 - typescript: '@typescript/typescript6@6.0.2' - transitivePeerDependencies: - - '@swc/types' - - supports-color - - '@swc-node/sourcemap-support@0.6.1': - dependencies: - source-map-support: 0.5.21 - tslib: 2.8.1 - - '@swc/core-darwin-arm64@1.15.47': - optional: true - - '@swc/core-darwin-x64@1.15.47': - optional: true - - '@swc/core-linux-arm-gnueabihf@1.15.47': - optional: true - - '@swc/core-linux-arm64-gnu@1.15.47': - optional: true - - '@swc/core-linux-arm64-musl@1.15.47': - optional: true - - '@swc/core-linux-ppc64-gnu@1.15.47': - optional: true - - '@swc/core-linux-s390x-gnu@1.15.47': - optional: true - - '@swc/core-linux-x64-gnu@1.15.47': - optional: true - - '@swc/core-linux-x64-musl@1.15.47': - optional: true - - '@swc/core-win32-arm64-msvc@1.15.47': - optional: true - - '@swc/core-win32-ia32-msvc@1.15.47': - optional: true - - '@swc/core-win32-x64-msvc@1.15.47': - optional: true - - '@swc/core@1.15.47': - dependencies: - '@swc/counter': 0.1.3 - '@swc/types': 0.1.27 - optionalDependencies: - '@swc/core-darwin-arm64': 1.15.47 - '@swc/core-darwin-x64': 1.15.47 - '@swc/core-linux-arm-gnueabihf': 1.15.47 - '@swc/core-linux-arm64-gnu': 1.15.47 - '@swc/core-linux-arm64-musl': 1.15.47 - '@swc/core-linux-ppc64-gnu': 1.15.47 - '@swc/core-linux-s390x-gnu': 1.15.47 - '@swc/core-linux-x64-gnu': 1.15.47 - '@swc/core-linux-x64-musl': 1.15.47 - '@swc/core-win32-arm64-msvc': 1.15.47 - '@swc/core-win32-ia32-msvc': 1.15.47 - '@swc/core-win32-x64-msvc': 1.15.47 - - '@swc/counter@0.1.3': {} - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@swc/types@0.1.27': - dependencies: - '@swc/counter': 0.1.3 - '@tsconfig/bun@1.0.10': {} '@tybys/wasm-util@0.10.3': @@ -9370,37 +8951,6 @@ snapshots: dependencies: '@types/node': 26.2.0 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': - optional: true - - '@typescript/native-preview@7.0.0-dev.20260707.2': - optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260707.2 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -9461,10 +9011,6 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@typescript/typescript6@6.0.2': - dependencies: - '@typescript/old': typescript@6.0.3 - '@ungap/structured-clone@1.3.3': {} '@vercel/detect-agent@1.2.5': {} @@ -10123,7 +9669,7 @@ snapshots: clsx@2.1.1: {} - cluster-key-slot@1.1.1: {} + cluster-key-slot@1.1.2: {} cnfast@0.1.0: {} @@ -10231,14 +9777,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig@9.0.2(@typescript/typescript6@6.0.2): + cosmiconfig@9.0.2(typescript@7.0.2): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.3.1 parse-json: 5.2.0 optionalDependencies: - typescript: '@typescript/typescript6@6.0.2' + typescript: 7.0.2 cross-spawn@7.0.6: dependencies: @@ -10297,8 +9843,6 @@ snapshots: delayed-stream@1.0.0: {} - denque@2.1.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -10364,13 +9908,11 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-rc.108: + effect@4.0.0-rc.111: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 - kubernetes-types: 1.30.0 - msgpackr: 2.0.4 - uuid: 14.0.1 + msgpackr: 2.0.5 ejs@5.0.1: {} @@ -10664,8 +10206,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - fast-sha256@1.3.0: {} fast-string-truncated-width@3.0.3: {} @@ -11276,18 +10816,6 @@ snapshots: inline-style-parser@0.2.7: {} - ioredis@5.11.1: - dependencies: - '@ioredis/commands': 1.10.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@7.2.0) - denque: 2.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ip-address@10.3.1: {} ipaddr.js@1.9.1: {} @@ -11484,8 +11012,6 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 - kubernetes-types@1.30.0: {} - lightningcss-android-arm64@1.33.0: optional: true @@ -12171,7 +11697,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true - msgpackr@2.0.4: + msgpackr@2.0.5: optionalDependencies: msgpackr-extract: 3.0.4 @@ -12278,7 +11804,7 @@ snapshots: npm@11.19.0: {} - nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.47): + nx@23.1.1: dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -12411,8 +11937,6 @@ snapshots: '@nx/nx-linux-x64-musl': 23.1.1 '@nx/nx-win32-arm64-msvc': 23.1.1 '@nx/nx-win32-x64-msvc': 23.1.1 - '@swc-node/register': 1.12.1(@swc/core@1.15.47)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2) - '@swc/core': 1.15.47 object-assign@4.1.1: {} @@ -12678,7 +12202,7 @@ snapshots: pg-copy-streams@7.0.0: {} - pg-cursor@2.21.0(pg@8.23.0): + pg-cursor@2.22.0(pg@8.23.0): dependencies: pg: 8.23.0 @@ -12776,8 +12300,6 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.2.0 - pirates@4.0.7: {} - pkce-challenge@5.0.1: {} pkg-conf@2.1.0: @@ -13046,11 +12568,16 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 - redis-errors@1.2.0: {} - - redis-parser@3.0.0: + redis@6.2.1: dependencies: - redis-errors: 1.2.0 + '@redis/bloom': 6.2.1(@redis/client@6.2.1) + '@redis/client': 6.2.1 + '@redis/json': 6.2.1(@redis/client@6.2.1) + '@redis/search': 6.2.1(@redis/client@6.2.1) + '@redis/time-series': 6.2.1(@redis/client@6.2.1) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' regex-recursion@6.0.2: dependencies: @@ -13215,15 +12742,15 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - semantic-release@25.0.9(@typescript/typescript6@6.0.2): + semantic-release@25.0.9(typescript@7.0.2): dependencies: - '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.9(@typescript/typescript6@6.0.2)) + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.9(typescript@7.0.2)) '@semantic-release/error': 4.0.0 - '@semantic-release/github': 12.0.9(semantic-release@25.0.9(@typescript/typescript6@6.0.2)) - '@semantic-release/npm': 13.1.5(semantic-release@25.0.9(@typescript/typescript6@6.0.2)) - '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.9(@typescript/typescript6@6.0.2)) + '@semantic-release/github': 12.0.9(semantic-release@25.0.9(typescript@7.0.2)) + '@semantic-release/npm': 13.1.5(semantic-release@25.0.9(typescript@7.0.2)) + '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.9(typescript@7.0.2)) aggregate-error: 5.0.0 - cosmiconfig: 9.0.2(@typescript/typescript6@6.0.2) + cosmiconfig: 9.0.2(typescript@7.0.2) debug: 4.4.3(supports-color@7.2.0) env-ci: 11.2.0 execa: 9.6.1 @@ -13432,11 +12959,6 @@ snapshots: source-map-js@1.2.1: {} - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - source-map@0.6.1: {} source-map@0.7.6: {} @@ -13483,8 +13005,6 @@ snapshots: stackback@0.0.2: {} - standard-as-callback@2.1.0: {} - standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 @@ -13758,8 +13278,6 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript@6.0.3: {} - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -13891,8 +13409,6 @@ snapshots: utils-merge@1.0.1: {} - uuid@14.0.1: {} - validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -14089,6 +13605,8 @@ snapshots: ws@8.21.1: {} + ws@8.21.3: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 412aa60a09..d35f877dfd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,26 +6,23 @@ packages: allowBuilds: '@parcel/watcher': true '@launchql/protobufjs': true - "@swc/core": true esbuild: true msgpackr-extract: true nx: true sharp: true catalog: - "@effect/atom-react": "4.0.0-beta.107" - "@effect/platform-bun": "4.0.0-rc.108" - "@effect/platform-node": "4.0.0-rc.108" - "@effect/sql-pg": "4.0.0-rc.108" - "@effect/vitest": "4.0.0-rc.108" + "@effect/atom-react": "4.0.0-rc.111" + "@effect/platform-bun": "4.0.0-rc.111" + "@effect/platform-node": "4.0.0-rc.111" + "@effect/sql-pg": "4.0.0-rc.111" + "@effect/vitest": "4.0.0-rc.111" "@nx/devkit": "^23.1.1" - "@swc-node/register": "^1.12.1" - "@swc/core": "^1.15.47" "@tsconfig/bun": "^1.0.10" "@types/bun": "^1.3.14" - "@typescript/native-preview": "7.0.0-dev.20260707.2" + "typescript": "^7.0.2" "@vitest/coverage-istanbul": "^4.1.10" - "effect": "4.0.0-rc.108" + "effect": "4.0.0-rc.111" "knip": "^6.32.2" "nx": "^23.1.1" "oxfmt": "^0.63.0" @@ -37,21 +34,20 @@ catalog: blockExoticSubdeps: true overrides: - "@effect/platform-node-shared": "4.0.0-beta.107" # pg-topo's parser chain otherwise resolves bleeding-edge Node globals that conflict with Bun's web types. "@launchql/protobufjs>@types/node": "24.10.4" minimumReleaseAge: 10200 minimumReleaseAgeExclude: - - "@effect/atom-react@4.0.0-beta.107" - - "@effect/platform-bun@4.0.0-beta.107" - - "@effect/platform-node@4.0.0-beta.107" - - "@effect/platform-node-shared@4.0.0-beta.107" - - "@effect/sql-pg@4.0.0-beta.107" - - "@effect/vitest@4.0.0-beta.107" + - "@effect/atom-react@4.0.0-rc.111" + - "@effect/platform-bun@4.0.0-rc.111" + - "@effect/platform-node@4.0.0-rc.111" + - "@effect/platform-node-shared@4.0.0-rc.111" + - "@effect/sql-pg@4.0.0-rc.111" + - "@effect/vitest@4.0.0-rc.111" - "@supabase/pg-delta@1.0.0-alpha.42" - "@supabase/pg-topo@1.0.0-alpha.5" - - "effect@4.0.0-beta.107" + - "effect@4.0.0-rc.111" supportedArchitectures: cpu: diff --git a/tools/nx-plugins/package.json b/tools/nx-plugins/package.json index 10615425b1..04b95d8394 100644 --- a/tools/nx-plugins/package.json +++ b/tools/nx-plugins/package.json @@ -1,9 +1,10 @@ { "name": "@supabase/nx-plugins", "private": true, + "type": "module", "dependencies": { "@nx/devkit": "catalog:", - "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript": "catalog:", "vitest": "catalog:" } } diff --git a/tools/nx-plugins/src/knip.plugin.ts b/tools/nx-plugins/src/knip.plugin.ts index 8ef6ca5c65..04710ed590 100644 --- a/tools/nx-plugins/src/knip.plugin.ts +++ b/tools/nx-plugins/src/knip.plugin.ts @@ -1,6 +1,6 @@ import type { CreateNodesV2 } from "@nx/devkit"; import { dirname } from "node:path"; -import { readPkgJson } from "./parse-pkg-json"; +import { readPkgJson } from "./parse-pkg-json.ts"; export interface KnipPluginOptions {} diff --git a/tools/nx-plugins/src/oxfmt.plugin.ts b/tools/nx-plugins/src/oxfmt.plugin.ts index ee5698e340..95de534710 100644 --- a/tools/nx-plugins/src/oxfmt.plugin.ts +++ b/tools/nx-plugins/src/oxfmt.plugin.ts @@ -1,6 +1,6 @@ import type { CreateNodesV2 } from "@nx/devkit"; import { dirname } from "node:path"; -import { readPkgJson } from "./parse-pkg-json"; +import { readPkgJson } from "./parse-pkg-json.ts"; export interface OxfmtPluginOptions {} diff --git a/tools/nx-plugins/src/oxlint.plugin.ts b/tools/nx-plugins/src/oxlint.plugin.ts index 5417404a46..4901e89fa5 100644 --- a/tools/nx-plugins/src/oxlint.plugin.ts +++ b/tools/nx-plugins/src/oxlint.plugin.ts @@ -1,6 +1,6 @@ import type { CreateNodesV2 } from "@nx/devkit"; import { dirname } from "node:path"; -import { readPkgJson } from "./parse-pkg-json"; +import { readPkgJson } from "./parse-pkg-json.ts"; export interface OxlintPluginOptions {} diff --git a/tools/nx-plugins/src/tsgo.plugin.ts b/tools/nx-plugins/src/typescript.plugin.ts similarity index 75% rename from tools/nx-plugins/src/tsgo.plugin.ts rename to tools/nx-plugins/src/typescript.plugin.ts index ac3fcca921..48dee65df4 100644 --- a/tools/nx-plugins/src/tsgo.plugin.ts +++ b/tools/nx-plugins/src/typescript.plugin.ts @@ -1,16 +1,16 @@ import type { CreateNodesV2 } from "@nx/devkit"; import { dirname } from "node:path"; -import { readPkgJson } from "./parse-pkg-json"; +import { readPkgJson } from "./parse-pkg-json.ts"; -export interface TsgoPluginOptions {} +export interface TypeScriptPluginOptions {} -export const createNodesV2: CreateNodesV2 = [ +export const createNodesV2: CreateNodesV2 = [ "{apps,packages}/*/package.json", (packageJsonFiles, _options, context) => { return packageJsonFiles.flatMap((packageJsonPath) => { const pkgJson = readPkgJson(context.workspaceRoot, packageJsonPath); - if (!pkgJson.devDependencies?.["@typescript/native-preview"]) return []; + if (!pkgJson.devDependencies?.typescript) return []; const projectRoot = dirname(packageJsonPath); @@ -22,10 +22,10 @@ export const createNodesV2: CreateNodesV2 = [ [projectRoot]: { targets: { "types:check": { - command: "tsgo --noEmit", + command: "tsc --noEmit", options: { cwd: "{projectRoot}" }, cache: true, - inputs: ["default", { externalDependencies: ["@typescript/native-preview"] }], + inputs: ["default", { externalDependencies: ["typescript"] }], }, }, metadata: { From 7e1ae3c9c93a2a0da10429329609c1ca0661c006 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:35:03 +0000 Subject: [PATCH 20/63] fix(deps): bump the npm-major group across 1 directory with 6 updates (#6291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.229` | `0.3.232` | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.116.0` | `0.117.1` | | [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.48.2` | `5.49.0` | | [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.14.3` | `16.14.4` | | [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.14.3` | `16.14.4` | | [next](https://github.com/vercel/next.js) | `16.3.0` | `16.3.1` | Updates `@anthropic-ai/claude-agent-sdk` from 0.3.229 to 0.3.232
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.232

What's changed

  • Subagent MCP tool_result frames whose result carries _meta now emit tool_use_result as { content, _meta } (matching main-loop frames) instead of a bare value
  • /context result messages now carry a structured context_usage payload (new SDKContextUsage type), so consumers can render the context-usage card without parsing the markdown table
  • vcs_state_changed events now populate the branch field for push operations, sourced from the pushed ref

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.232
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.232
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.232
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.232

v0.3.231

What's changed

  • Updated to parity with Claude Code v2.1.231

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.231
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.231
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.231
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.231
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.232

  • Subagent MCP tool_result frames whose result carries _meta now emit tool_use_result as { content, _meta } (matching main-loop frames) instead of a bare value
  • /context result messages now carry a structured context_usage payload (new SDKContextUsage type), so consumers can render the context-usage card without parsing the markdown table
  • vcs_state_changed events now populate the branch field for push operations, sourced from the pushed ref

0.3.231

  • Updated to parity with Claude Code v2.1.231

0.3.230

  • Updated to parity with Claude Code v2.1.230
Commits

Updates `@anthropic-ai/sdk` from 0.116.0 to 0.117.1
Release notes

Sourced from @​anthropic-ai/sdk's releases.

sdk: v0.117.1

0.117.1 (2026-08-13)

Full Changelog: sdk-v0.117.0...sdk-v0.117.1

Chores

  • ci: allow manually re-publishing a package to npm from the release workflow (af60c1f)
  • internal: tag uploaded preview builds with the branch name (#295) (228f44e)

sdk: v0.117.0

0.117.0 (2026-08-13)

Full Changelog: sdk-v0.116.0...sdk-v0.117.0

Features

  • api: add output_behavior to dream creation (create a new memory store or update the input store in place) (6a5bd0f)

Bug Fixes

  • build: include dotfiles when flattening dist during git installs (917dbbb)
  • client: add models (a7bfbb1)
  • messages: honor per-request timeout in the non-streaming long-request check (#272) (0fdd8a8)
  • streaming: apply all message_delta fields when accumulating streamed messages (#289) (7b82659)
  • tool-runner: forward the response container id to the next request (#271) (5bdee4a)
  • tools: align path resolution, skill-archive members, and heartbeat bounds with the other SDKs (#264) (5fbc729)

Chores

  • ci: run breaking-change detection as a ci.yml job on every push (c34c1d5)
  • internal: switch from yarn to pnpm (f4eeea0)
  • tools: escape backslashes in skill archive exclusion patterns (#311) (67ede1c)

Documentation

  • api: clarify that user profile name is optional for resold profiles (1b6fed5)
Changelog

Sourced from @​anthropic-ai/sdk's changelog.

0.117.1 (2026-08-13)

Full Changelog: sdk-v0.117.0...sdk-v0.117.1

Chores

  • ci: allow manually re-publishing a package to npm from the release workflow (af60c1f)
  • internal: tag uploaded preview builds with the branch name (#295) (228f44e)

0.117.0 (2026-08-13)

Full Changelog: sdk-v0.116.0...sdk-v0.117.0

Features

  • api: add output_behavior to dream creation (create a new memory store or update the input store in place) (6a5bd0f)

Bug Fixes

  • build: include dotfiles when flattening dist during git installs (917dbbb)
  • client: add models (a7bfbb1)
  • messages: honor per-request timeout in the non-streaming long-request check (#272) (0fdd8a8)
  • streaming: apply all message_delta fields when accumulating streamed messages (#289) (7b82659)
  • tool-runner: forward the response container id to the next request (#271) (5bdee4a)
  • tools: align path resolution, skill-archive members, and heartbeat bounds with the other SDKs (#264) (5fbc729)

Chores

  • ci: run breaking-change detection as a ci.yml job on every push (c34c1d5)
  • internal: switch from yarn to pnpm (f4eeea0)
  • tools: escape backslashes in skill archive exclusion patterns (#311) (67ede1c)

Documentation

  • api: clarify that user profile name is optional for resold profiles (1b6fed5)
Commits
  • 64a1e8e chore: release main
  • 0281a29 chore(ci): allow manually re-publishing a package to npm from the release wor...
  • f3e060b chore(internal): tag uploaded preview builds with the branch name (#295)
  • 6fcfb2c chore: release main
  • 1a1af33 codegen metadata
  • d6b8f40 chore: release main
  • 5edda86 chore(tools): escape backslashes in skill archive exclusion patterns (#311)
  • 07cf28c chore(internal): switch from yarn to pnpm
  • a751543 docs(api): clarify that user profile name is optional for resold profiles
  • 0c74ed0 fix(build): include dotfiles when flattening dist during git installs
  • Additional commits viewable in compare view

Updates `posthog-node` from 5.48.2 to 5.49.0
Release notes

Sourced from posthog-node's releases.

posthog-node@5.49.0

5.49.0

Minor Changes

  • #4289 c9086de Thanks @​carlos-marchal-ph! - Public beta captureAi() / captureAiImmediate(): AI events on a dedicated isolated endpoint with the event UUID returned. New enableFullAiCapture option replaces the internal _useAiLane / _enableMultimodalCapture; wrappers route through the AI endpoint and skip redaction/truncation when set (privacy mode still wins). (2026-08-13)

Patch Changes

  • Updated dependencies [c9086de]:
    • @​posthog/core@​1.48.0
Changelog

Sourced from posthog-node's changelog.

5.49.0

Minor Changes

  • #4289 c9086de Thanks @​carlos-marchal-ph! - Public beta captureAi() / captureAiImmediate(): AI events on a dedicated isolated endpoint with the event UUID returned. New enableFullAiCapture option replaces the internal _useAiLane / _enableMultimodalCapture; wrappers route through the AI endpoint and skip redaction/truncation when set (privacy mode still wins). (2026-08-13)

Patch Changes

  • Updated dependencies [c9086de]:
    • @​posthog/core@​1.48.0
Commits
  • 5c5d26c chore: update versions and lockfile [version bump]
  • c9086de feat(aio): public beta captureAi with dedicated AI capture lane (#4289)
  • See full diff in compare view

Updates `fumadocs-core` from 16.14.3 to 16.14.4
Release notes

Sourced from fumadocs-core's releases.

fumadocs@16.14.4

  • @​fumadocs/base-ui@​16.14.4
  • fumadocs-core@16.14.4
  • fumadocs-ui@16.14.4

Introduce @fumari/image-size, replacing image-size in remarkImage

A fork of probe-image-size with no dependencies of its own.

import { probe, imageSize } from
'@fumari/image-size';

await probe('./public/banner.png'); // { width: 1200, height: 630, type: 'png', mime: 'image/png' }
await probe('https://example.com/banner.png', { timeout: 5000 });

imageSize(bytes); // the same result, or null

remarkImage now uses it in both fumadocs-core and @fumadocs/satteri. Remote images are no longer downloaded in full just to be measured, and redirects are followed. Sizes are always in pixels, so an SVG sized in em or pt is converted instead of being skipped. Remote requests also time out after 30 seconds by default.

One behaviour difference worth knowing: the supported formats are avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved and go through onError instead.

Sequential scanning stops after 512 KB, but that never loses an image: the one format that stores its dimensions past that point — TIFF with a trailing IFD — is resolved by following the header's pointer with a targeted read, using an HTTP Range request for remote files (and skipping through the body when the server ignores ranges).

Commits

Updates `fumadocs-ui` from 16.14.3 to 16.14.4
Release notes

Sourced from fumadocs-ui's releases.

fumadocs@16.14.4

  • @​fumadocs/base-ui@​16.14.4
  • fumadocs-core@16.14.4
  • fumadocs-ui@16.14.4

Introduce @fumari/image-size, replacing image-size in remarkImage

A fork of probe-image-size with no dependencies of its own.

import { probe, imageSize } from
'@fumari/image-size';

await probe('./public/banner.png'); // { width: 1200, height: 630, type: 'png', mime: 'image/png' }
await probe('https://example.com/banner.png', { timeout: 5000 });

imageSize(bytes); // the same result, or null

remarkImage now uses it in both fumadocs-core and @fumadocs/satteri. Remote images are no longer downloaded in full just to be measured, and redirects are followed. Sizes are always in pixels, so an SVG sized in em or pt is converted instead of being skipped. Remote requests also time out after 30 seconds by default.

One behaviour difference worth knowing: the supported formats are avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved and go through onError instead.

Sequential scanning stops after 512 KB, but that never loses an image: the one format that stores its dimensions past that point — TIFF with a trailing IFD — is resolved by following the header's pointer with a targeted read, using an HTTP Range request for remote files (and skipping through the body when the server ignores ranges).

Commits

Updates `next` from 16.3.0 to 16.3.1
Release notes

Sourced from next's releases.

v16.3.1

What's Changed

Full Changelog: https://github.com/vercel/next.js/compare/v16.3.0...v16.3.1

v16.3.1-canary.26

Misc Changes

  • docs: document deploymentId build ID override and Pages Router skew in 16.2: #97645
  • Upgrade React from eb8feb71-20260814 to eafeac09-20260819: #97636
  • Turbopack: rename to use turbopack: no side effects: #94427
  • refactor: move useDynamic{Route,Search}Params to reduce snapshot churn: #97360
  • [PPF] unstable_navigation(): #96908
  • [PPF] Scaffold unstable_navigation(): #97236
  • docs: Explicit cache output description: #97548
  • Improve Cache Components sync IO migration guidance: #97572
  • [test] Use a non-native stub for the server externals list test: #97614
  • Avoid GitHub API rate limits for create-next-app examples: #97612
  • [test] Cover the prerender worker-thread backend with an addon we control: #97543
  • [test] Convert the prerender-native-module suite to local fixture packages: #97542
  • [test] Replace the turbopack-reports sqlite3 dependency with a local addon fixture: #97541
  • [test] Drop the dead sqlite3 build approval from the sharp-basic suite: #97540
  • [ci] Authenticate Turborepo remote caching with OIDC instead of a static PAT: #97590
  • Remove HmrTarget: #97253
  • Keep HMR instructions typed until serialization: #96569
  • Serialize frozen collections by value only: #96686

Credits

... (truncated)

Commits
  • 3d32eb8 v16.3.1
  • 2b4b1ec [backport] Revert i18n localization change for dynamic Pages API routes (#949...
  • 228df5f [backport] Retain fewer stale cache versions and use a TTL, plus the mtime fa...
  • 16eab3a [backport] Fix: Optimistic routing bugs leading to repeated prefetch loops (#...
  • 75548c9 [backport] Fix Nav Inspector request loop on repeat captures (#97326)
  • f6bd714 [backport] [test] Compile the middleware redirect routes up front in dev (#97...
  • 43c1c60 [16.3] [ci] Use OIDC tokens to read private preview builds (#97258)
  • 9dbb357 [backport] Encode the cache item name built by unstable_cache (#97313)
  • 323a17c [backport] Discard only cache entries that predate a tag revalidation, and re...
  • 6d67f46 [backport] Keep the dev validation worker alive across HMR updates (#97315)
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 6 +- apps/docs/package.json | 6 +- pnpm-lock.yaml | 303 ++++++++++++++++++++--------------------- 3 files changed, 157 insertions(+), 158 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 8cac32db58..96706fd08e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,8 +43,8 @@ "jose": "^6.2.8" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.229", - "@anthropic-ai/sdk": "^0.116.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.232", + "@anthropic-ai/sdk": "^0.117.1", "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", @@ -77,7 +77,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.23.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.48.2", + "posthog-node": "^5.49.0", "react": "^19.2.8", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", diff --git a/apps/docs/package.json b/apps/docs/package.json index cd850092d0..8ccce26920 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,10 +9,10 @@ "types:check": "pnpm exec fumadocs-mdx source.config.ts .source && tsc --noEmit --incremental false" }, "dependencies": { - "fumadocs-core": "^16.14.3", + "fumadocs-core": "^16.14.4", "fumadocs-mdx": "^15.2.3", - "fumadocs-ui": "^16.14.3", - "next": "16.3.0", + "fumadocs-ui": "^16.14.4", + "next": "16.3.1", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6312ce1a56..cb43a6cef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,11 +103,11 @@ importers: version: 6.2.8 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.229 - version: 0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.232 + version: 0.3.232(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.116.0 - version: 0.116.0(zod@4.4.3) + specifier: ^0.117.1 + version: 0.117.1(zod@4.4.3) '@clack/prompts': specifier: ^1.7.0 version: 1.7.0 @@ -205,8 +205,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.48.2 - version: 5.48.2 + specifier: ^5.49.0 + version: 5.49.0 react: specifier: ^19.2.8 version: 19.2.8 @@ -294,17 +294,17 @@ importers: apps/docs: dependencies: fumadocs-core: - specifier: ^16.14.3 - version: 16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + specifier: ^16.14.4 + version: 16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) fumadocs-mdx: specifier: ^15.2.3 - version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) fumadocs-ui: - specifier: ^16.14.3 - version: 16.14.3(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^16.14.4 + version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next: - specifier: 16.3.0 - version: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.3.1 + version: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -582,60 +582,60 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229': - resolution: {integrity: sha512-yVrJwSG9ur001ggDHKPUHlRuHKwbi2ETfby+4wlktRcQ5sAXqcSeS6B4T+IsXk8NDDHKXbCxpAPnCB5dz1Ac9g==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.232': + resolution: {integrity: sha512-+/4PX+dwmQAjlOlooocwa3kClulZfMo133xQH3LYDlK7D5bzze16lwlDGPVAYGEarpvXg7G5JK8QjfWAJ2HYbg==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229': - resolution: {integrity: sha512-MOhOwgh9fqnX/rqeVJzy+NNFx+xXX2Kej/jx33xTV+4J5aJ2H1SwjZ+r3f/k1MMQmPvkrE0GHnMYrpu4hfvZ5Q==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.232': + resolution: {integrity: sha512-EHZ1Y3aGyZ2mFZ6QLR1bM3/HiIn2cLrPjU+k3/oCIW6omJFodfzf410aWjDPSnMj7CE4d6t7MSjBWekMUbcv0g==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229': - resolution: {integrity: sha512-S0XcKhUXLFdEv1Pq+aYiV7fDlTa/n+bajBNkKv12eUzjCUFsM/TfSyU5shnkQg9y7NtoB4owK/rezm5hHD1ybg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.232': + resolution: {integrity: sha512-XkLcb9UT/l42Rtw7KBApzgxUe/kwoWJ9KCPcVEnYojzIvVR+AwBCl/QReNU5+6c72w48dYBMmLebI64gQbN0tg==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229': - resolution: {integrity: sha512-xSPUmKNik7HEYsbdUFywbjvGzUth0P2KohetJixSOrBha63ZRun449ssEHU55VX3qH+YO5ENn129l5TGFzMNGQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.232': + resolution: {integrity: sha512-wW2opwA5s7gLghjU6B2ADMAtoc7bAZMevUzi4g+1PXMJ8MGcPvbnx92EDYJrVDcZmAl1+fz19XyPsaShlisTzw==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229': - resolution: {integrity: sha512-9gJsr0elKyV21ln+Mza5kYiSAsqSAWFiHuaT+MiXAycBK73UbzRVLBg87dajkGagg6H1u5ZtPSkqNXzA2xArmA==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.232': + resolution: {integrity: sha512-L1x2ge9NpXMLTczmT44TKPQ88PHE+gsCakQMVEOa8rXFvfBoqvcpxM0DT8wrqYWUHhQEYR8NXxQTP9a1NVRj8Q==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229': - resolution: {integrity: sha512-Cs0NxWVL/Up8kEh//P2cE+3VHrDxoSmWUP3EvI/GU+1RRGZPDD80UN1Rvl3TMC5QPMq+IYPjh1yncnxZ6j8aNw==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.232': + resolution: {integrity: sha512-6Px1xDiwQyLkSxwRQ34/kPA8WMXQ2rHYGkwockND7+9yMw+ShI3AfLkyi9G7JtJHObWFT6B82eSHjDS/Fy+9hQ==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229': - resolution: {integrity: sha512-2fkljxQweZUa41RrlhLh+VdZrJ4kYzEBHGD8sc+Xy2bjkF8T4GTmsE8eKSWN3NfSCQOLHgK7Bxfm6+2n88WJMg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.232': + resolution: {integrity: sha512-fDiuwL5dm1elOy7fNp4Qmdor8so4R8npj2pUGQA1G/xKfJhaK236aTWezvZid3L/O7cRdFeN9IVofOAlWLQcsw==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229': - resolution: {integrity: sha512-2M2/KHJTGqzduA8eRZDu1vWSFkMqKl9JZMqJmaJ2THIR2g7jBUAzK0B57UzkO9uQXoXWFH7P91OSKKGA4rwfwQ==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.232': + resolution: {integrity: sha512-Hc/9uy1BI9mqKVyB1b/zoUnm3MFgtVNzQY6p5zgaq9DaIjCKDpR4a4L1aDZM4lMqUcWFMZM+u7juOhh+m39NBQ==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.229': - resolution: {integrity: sha512-ZxSFO7cNSz7jqsOmoGkBtvMT4JGWb2a8PUe6doAydZXH6mm6FsoUf+duSUm2rDrbv7OMoD8HDGwNZv5P3woE3w==} + '@anthropic-ai/claude-agent-sdk@0.3.232': + resolution: {integrity: sha512-8od7hJk9fZnF1/oYYiR9PvroGbZRQrpmNgKirjHNGoj5ur5YcAZLohI70XVUAUe3KvjB1msLxtkvmlAT9sqFAg==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.116.0': - resolution: {integrity: sha512-4UEapYQ+epLEMsAuLZDvW8ExVSOtHD8a7zTyLzhw0H9RXJ1eilPgmqhjwgcdg22diwx13spw6fJ4rONZ+bS7Ww==} + '@anthropic-ai/sdk@0.117.1': + resolution: {integrity: sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -996,6 +996,9 @@ packages: tailwindcss: optional: true + '@fumari/image-size@0.1.0': + resolution: {integrity: sha512-x2o9u6P8uKUK15B8XgEoRhR3PgLoLSbQKK6FUCd14JzumEw+e8FXZPelij/dZ4VQVMp4r61VD3DcvSo3aEhLAA==} + '@hono/node-server@2.0.12': resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} engines: {node: '>=20'} @@ -1339,57 +1342,57 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/env@16.3.0': - resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} + '@next/env@16.3.1': + resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==} - '@next/swc-darwin-arm64@16.3.0': - resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} + '@next/swc-darwin-arm64@16.3.1': + resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.0': - resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} + '@next/swc-darwin-x64@16.3.1': + resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.0': - resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} + '@next/swc-linux-arm64-gnu@16.3.1': + resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.0': - resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} + '@next/swc-linux-arm64-musl@16.3.1': + resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.0': - resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} + '@next/swc-linux-x64-gnu@16.3.1': + resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.0': - resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} + '@next/swc-linux-x64-musl@16.3.1': + resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.0': - resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} + '@next/swc-win32-arm64-msvc@16.3.1': + resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.0': - resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} + '@next/swc-win32-x64-msvc@16.3.1': + resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2142,11 +2145,11 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@posthog/core@1.47.1': - resolution: {integrity: sha512-d38C5DulL3gCox4g0VGyb/Lhn548fBvj9f35LZGVasPspOs3jyApxROJyDXoWwIRivOjCOIShZQAdYkZVn9J3w==} + '@posthog/core@1.48.0': + resolution: {integrity: sha512-ezKjVLw9y3Q235PUY+2hRr5DN9t6j2jF1mFAvnvhCCu/Ha6/qBv3zmVJBaHu9PnqqSNtx2St3ggN8Z3TTu/12A==} - '@posthog/types@1.403.0': - resolution: {integrity: sha512-QbkO0epmdq38xhxwP214YRi0vgpZiXqhamaTjKT6kVGJYTtsE5qZ1GTgLp12IYehg/rd+whYYErH3/DeX8pj3Q==} + '@posthog/types@1.404.0': + resolution: {integrity: sha512-/Y1zKv8SdwkK725SkmgT5QVYnXE7Fi23SPDCZ5Ybu27gTQub4yMTKWuUQekW+gkSKBZ0LyYCQK52mhyMMigMBw==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2797,8 +2800,8 @@ packages: '@opentelemetry/api': optional: true - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tsconfig/bun@1.0.10': resolution: {integrity: sha512-5AV5YknQjNyoYzZ/8NG0dawqew/wH+x7ANiCfCIn29qo0cdbd1EryvFD1k5NSZWLBMOI/fGqMIaxi58GPIP9Cg==} @@ -3390,8 +3393,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.13: - resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} engines: {node: '>=6.0.0'} hasBin: true @@ -4176,15 +4179,12 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.43.0: - resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + framer-motion@13.1.0: + resolution: {integrity: sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -4210,8 +4210,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.14.3: - resolution: {integrity: sha512-xoGy6YelmU8GD4RKUiSuraFnRW91DqBM328Gs/YasltLrnMDWgEaYMKAcrGLVrikpqJkFx+etHo8BcClOMNt+A==} + fumadocs-core@16.14.4: + resolution: {integrity: sha512-vD1gVDwYKATW54D3tD/jPcB7GGipJ8qPXa85gCV3HNhC8u8SkbJdvu/QYklo6gTpULy+J/Aj+5vlg96zE39+Yg==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -4306,12 +4306,12 @@ packages: vite: optional: true - fumadocs-ui@16.14.3: - resolution: {integrity: sha512-ASL9BgFxSe6VrbQ60nxVfpnKBPboeADp330JQvyEAqR1U8uw0T1+Vko8ySXRAWbivdZfYK5fZbCh78EOAMTEqw==} + fumadocs-ui@16.14.4: + resolution: {integrity: sha512-EW3pRRqQ1G1/4RVTsEhCaJTvXGHCRe92hySyIb5fAecJ6MVO2TNymemqqB5mxmXQGxNSOprtEybrB2mR0yJc8g==} peerDependencies: '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.14.3 + fumadocs-core: 16.14.4 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -5274,21 +5274,18 @@ packages: engines: {node: '>=10'} hasBin: true - motion-dom@12.43.0: - resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + motion-dom@13.0.0: + resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} - motion-utils@12.39.0: - resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} - motion@12.43.0: - resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} + motion@13.1.0: + resolution: {integrity: sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -5342,8 +5339,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.0: - resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} + next@16.3.1: + resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5849,8 +5846,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.48.2: - resolution: {integrity: sha512-3Ni5upqpbUXL1QM2oU4sL9Y3OycDGgrKI9aJOGHbcF9xkPwR18cq481Tkvn6/CCPbl04p3tjdwQNkGHVveQcag==} + posthog-node@5.49.0: + resolution: {integrity: sha512-w3vPYmiWIWw0XlRDeRH0TbeRKnHvlQcU7xDwDN7jNm6JpoFQDUyValGjBpQ+Qvv6gmYUaNM2XBSJeeqcB1FtCQ==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -7008,8 +7005,8 @@ packages: yuku-ast@0.8.4: resolution: {integrity: sha512-s7EWfWIQkaGmsGnyr/BU0jli9YTN5TvrKIsSmALyRD9elumDQInuhv0BrVObENKVCxr9W3Ikmnx5u02KvfuUmw==} - zbsearch@3.3.4: - resolution: {integrity: sha512-xGsv9rIwrili/fpLpVwmnCovEcvaAJg1ey+3Ur0+m3x1mnGoVO71iAwn4op420QLGNsQJNmScZjFq5TQ+cRi/g==} + zbsearch@4.0.0: + resolution: {integrity: sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==} engines: {node: '>= 20.0.0'} zod-to-json-schema@3.25.2: @@ -7046,46 +7043,46 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.232': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.232(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.116.0(zod@4.4.3) + '@anthropic-ai/sdk': 0.117.1(zod@4.4.3) '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.229 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.229 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.229 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.229 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.229 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.229 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.229 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.229 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.232 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.232 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.232 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.232 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.232 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.232 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.232 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.232 - '@anthropic-ai/sdk@0.116.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.117.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -7449,6 +7446,8 @@ snapshots: '@fumadocs/tailwind@0.1.1': {} + '@fumari/image-size@0.1.0': {} + '@hono/node-server@2.0.12(hono@4.12.32)': dependencies: hono: 4.12.32 @@ -7746,30 +7745,30 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.0': {} + '@next/env@16.3.1': {} - '@next/swc-darwin-arm64@16.3.0': + '@next/swc-darwin-arm64@16.3.1': optional: true - '@next/swc-darwin-x64@16.3.0': + '@next/swc-darwin-x64@16.3.1': optional: true - '@next/swc-linux-arm64-gnu@16.3.0': + '@next/swc-linux-arm64-gnu@16.3.1': optional: true - '@next/swc-linux-arm64-musl@16.3.0': + '@next/swc-linux-arm64-musl@16.3.1': optional: true - '@next/swc-linux-x64-gnu@16.3.0': + '@next/swc-linux-x64-gnu@16.3.1': optional: true - '@next/swc-linux-x64-musl@16.3.0': + '@next/swc-linux-x64-musl@16.3.1': optional: true - '@next/swc-win32-arm64-msvc@16.3.0': + '@next/swc-win32-arm64-msvc@16.3.1': optional: true - '@next/swc-win32-x64-msvc@16.3.0': + '@next/swc-win32-x64-msvc@16.3.1': optional: true '@noble/ciphers@1.3.0': {} @@ -8233,11 +8232,11 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@posthog/core@1.47.1': + '@posthog/core@1.48.0': dependencies: - '@posthog/types': 1.403.0 + '@posthog/types': 1.404.0 - '@posthog/types@1.403.0': {} + '@posthog/types@1.404.0': {} '@protobufjs/aspromise@1.1.2': {} @@ -8860,7 +8859,7 @@ snapshots: '@supabase/realtime-js': 2.112.3 '@supabase/storage-js': 2.112.3 - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -9437,7 +9436,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.13: {} + baseline-browser-mapping@2.11.14: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -9504,7 +9503,7 @@ snapshots: browserslist@4.28.6: dependencies: - baseline-browser-mapping: 2.11.13 + baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 electron-to-chromium: 1.5.389 node-releases: 2.0.51 @@ -10304,10 +10303,10 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + framer-motion@13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - motion-dom: 12.43.0 - motion-utils: 12.39.0 + motion-dom: 13.0.0 + motion-utils: 13.0.0 tslib: 2.8.1 optionalDependencies: react: 19.2.8 @@ -10328,8 +10327,9 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): dependencies: + '@fumari/image-size': 0.1.0 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 hast-util-to-estree: 3.1.3 @@ -10347,7 +10347,7 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.0 - zbsearch: 3.3.4 + zbsearch: 4.0.0 optionalDependencies: '@mdx-js/mdx': 3.1.1 '@types/estree-jsx': 1.0.5 @@ -10355,21 +10355,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.18 lucide-react: 1.31.0(react@19.2.8) - next: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): + fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) github-slugger: 2.0.0 magic-string: 1.1.0 mdast-util-mdx: 3.0.0 @@ -10388,14 +10388,14 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 rolldown: 1.1.5 vite: 8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.14.3(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1 @@ -10411,9 +10411,9 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) lucide-react: 1.31.0(react@19.2.8) - motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -10425,9 +10425,8 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - - '@emotion/is-prop-valid' - '@types/react-dom' - tailwindcss @@ -11667,15 +11666,15 @@ snapshots: mkdirp@1.0.4: {} - motion-dom@12.43.0: + motion-dom@13.0.0: dependencies: - motion-utils: 12.39.0 + motion-utils: 13.0.0 - motion-utils@12.39.0: {} + motion-utils@13.0.0: {} - motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + motion@13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + framer-motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tslib: 2.8.1 optionalDependencies: react: 19.2.8 @@ -11726,25 +11725,25 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.0 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.11.13 + '@next/env': 16.3.1 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 postcss: 8.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.0 - '@next/swc-darwin-x64': 16.3.0 - '@next/swc-linux-arm64-gnu': 16.3.0 - '@next/swc-linux-arm64-musl': 16.3.0 - '@next/swc-linux-x64-gnu': 16.3.0 - '@next/swc-linux-x64-musl': 16.3.0 - '@next/swc-win32-arm64-msvc': 16.3.0 - '@next/swc-win32-x64-msvc': 16.3.0 + '@next/swc-darwin-arm64': 16.3.1 + '@next/swc-darwin-x64': 16.3.1 + '@next/swc-linux-arm64-gnu': 16.3.1 + '@next/swc-linux-arm64-musl': 16.3.1 + '@next/swc-linux-x64-gnu': 16.3.1 + '@next/swc-linux-x64-musl': 16.3.1 + '@next/swc-win32-arm64-msvc': 16.3.1 + '@next/swc-win32-x64-msvc': 16.3.1 sharp: 0.35.3(@types/node@26.2.0) transitivePeerDependencies: - '@babel/core' @@ -12358,9 +12357,9 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.48.2: + posthog-node@5.49.0: dependencies: - '@posthog/core': 1.47.1 + '@posthog/core': 1.48.0 pretty-ms@9.3.0: dependencies: @@ -13676,7 +13675,7 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.8.4 - zbsearch@3.3.4: {} + zbsearch@4.0.0: {} zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: From d82c63764fd532cf5dca46d520e478d5452f41cf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 21 Aug 2026 16:43:04 +0000 Subject: [PATCH 21/63] chore: update Bun to 1.4.0 (#6293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - update the local and CI Bun runtime pins to 1.4.0 - refresh the generated Mise lock data and Bun type definitions - keep Istanbul coverage in place until Bun’s V8 coverage fix ships in a later release ## Context Bun 1.4.0’s V8 coverage path currently fails against this suite because it emits invalid zero-length ranges. This update therefore leaves the existing Istanbul provider unchanged and limits the change to the runtime and tooling upgrade. ## Binary size comparison Uncompressed executable sizes from the [successful preview build](https://github.com/supabase/cli/actions/runs/32490402284/job/96800354277), compared with the current production [v2.115.0](https://github.com/supabase/cli/releases/tag/v2.115.0) platform packages: | Platform | Preview Bun 1.4 | Production | Difference | |---|---:|---:|---:| | macOS arm64 | 69.30 MiB | 68.45 MiB | +0.85 MiB (+1.24%) | | macOS x64 | 75.79 MiB | 73.44 MiB | +2.35 MiB (+3.20%) | | Linux arm64, glibc | 91.74 MiB | 109.69 MiB | −17.95 MiB (−16.37%) | | Linux arm64, musl | 85.07 MiB | 104.13 MiB | −19.06 MiB (−18.31%) | | Linux x64, glibc | 92.36 MiB | 109.85 MiB | −17.49 MiB (−15.92%) | | Linux x64, musl | 86.39 MiB | 104.94 MiB | −18.55 MiB (−17.68%) | | Windows arm64 | 84.28 MiB | 118.51 MiB | −34.23 MiB (−28.88%) | | Windows x64 | 94.30 MiB | 121.08 MiB | −26.78 MiB (−22.12%) | Bun 1.4 reduces the Linux and Windows binaries by roughly 16–29%, while the macOS binaries increase by 1–3%. --------- Co-authored-by: kanad --- .bun-version | 2 +- .github/workflows/contribution-gate.yml | 4 +-- mise.lock | 46 ++++++++++++------------- pnpm-lock.yaml | 32 ++++++++--------- pnpm-workspace.yaml | 4 ++- 5 files changed, 45 insertions(+), 43 deletions(-) diff --git a/.bun-version b/.bun-version index 7962dcfdb6..88c5fb891d 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.3.13 +1.4.0 diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml index 68d3f3b639..af7cab6ac5 100644 --- a/.github/workflows/contribution-gate.yml +++ b/.github/workflows/contribution-gate.yml @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: "1.3.13" + bun-version-file: ".bun-version" - name: Evaluate contribution gate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: "1.3.13" + bun-version-file: ".bun-version" - name: Evaluate contribution gate across all open PRs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/mise.lock b/mise.lock index 02d24bb308..b249350cfb 100644 --- a/mise.lock +++ b/mise.lock @@ -1,52 +1,52 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html [[tools.bun]] -version = "1.3.13" +version = "1.4.0" backend = "core:bun" [tools.bun."platforms.linux-arm64"] -checksum = "sha256:70bae41b3908b0a120e1e58c5c8af30e74afae3b8d11b0d3fdd8e787ddfb4b22" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-aarch64.zip" +checksum = "sha256:4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-aarch64.zip" [tools.bun."platforms.linux-arm64-musl"] -checksum = "sha256:5385e978107ce4934298d8d6afe9bfbb898683f6cc23e6753a0da60bc60c5b81" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-aarch64-musl.zip" +checksum = "sha256:576300ce33ff16ffcd455bf178c2f095f9df845c6cc3d0284ba1c96ca0e80473" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-aarch64-musl.zip" [tools.bun."platforms.linux-x64"] -checksum = "sha256:79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64.zip" +checksum = "sha256:2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64.zip" [tools.bun."platforms.linux-x64-baseline"] -checksum = "sha256:9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64-baseline.zip" +checksum = "sha256:184fb4595f0d401a217cf7c78c1bc430ba83314dab7a8b94805babbf7fa7097f" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64-baseline.zip" [tools.bun."platforms.linux-x64-musl"] -checksum = "sha256:5b91a48f0b00df9fd2da8bff1a795d2659d842da966432969203f25da19d1c74" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64-musl.zip" +checksum = "sha256:83b5f12fd258dd8d4fdcaea65ede954366aa717dab399e20093ecab280d54e7a" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64-musl.zip" [tools.bun."platforms.linux-x64-musl-baseline"] -checksum = "sha256:88ca7c7ad235b498f549eea2f770f434e9f0f5e9ba95168a2d3a1f235184c394" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64-musl-baseline.zip" +checksum = "sha256:618c4bc1f94b02337ee210003c0b7c066f11548a8cdc5109df10db043dc47ca2" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64-musl-baseline.zip" [tools.bun."platforms.macos-arm64"] -checksum = "sha256:5467e3f65dba526b9fea98f0cce04efafc0c63e169733ec27b876a3ad32da190" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-darwin-aarch64.zip" +checksum = "sha256:c669e97f6164e1c96e0701748db98dfa77492908cbd8394c7557134a735de381" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-darwin-aarch64.zip" [tools.bun."platforms.macos-x64"] -checksum = "sha256:e5a6c8b64f419925232d111ecb13e25f0abf55e54f792341f987623fd0778009" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-darwin-x64.zip" +checksum = "sha256:1d0211b8f1dc991182344687ad15e72ee86f154845a5f7fa477994cd341dd9b0" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-darwin-x64.zip" [tools.bun."platforms.macos-x64-baseline"] -checksum = "sha256:a98ba6a480f22fda9b343626b906a4e26aa53618bf85d2bc5928ecf2ba45f0ed" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-darwin-x64-baseline.zip" +checksum = "sha256:da9b9f1b4ba766c6f299711f38dfaa98623e1ed9c40896aa53db803c52ec1fa0" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-darwin-x64-baseline.zip" [tools.bun."platforms.windows-x64"] -checksum = "sha256:85b14f3e0584218e9b63407b3aa6b90c4835ec5c32435c1f12cb6fc13667c7c9" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-windows-x64.zip" +checksum = "sha256:e6f093d39da486b20262ca8cdd5ed6a9e8bc9c2f275b78e6d3a0c5b28cc95901" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-windows-x64.zip" [tools.bun."platforms.windows-x64-baseline"] -checksum = "sha256:c68c7903c1190101590cc1b2129835f47211b3b37ae87759f2b97d6534aa3ad1" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-windows-x64-baseline.zip" +checksum = "sha256:b929c54a9badb104a16dedd23aab6152c86793ae653d4e6b13983ffd0c882a66" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-windows-x64-baseline.zip" [[tools.go]] version = "1.26.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb43a6cef0..3389356a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ catalogs: specifier: ^1.0.10 version: 1.0.10 '@types/bun': - specifier: ^1.3.14 - version: 1.3.14 + specifier: ^1.4.0 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: ^4.1.10 version: 4.1.10 @@ -155,7 +155,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@types/pg': specifier: ^8.21.0 version: 8.21.0 @@ -268,7 +268,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -348,7 +348,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -390,7 +390,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -440,7 +440,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -480,7 +480,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -529,7 +529,7 @@ importers: version: 1.0.10 '@types/bun': specifier: 'catalog:' - version: 1.3.14 + version: 1.4.0 '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -2812,8 +2812,8 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} - '@types/bun@1.3.14': - resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/bun@1.4.0': + resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3453,8 +3453,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.14: - resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bun-types@1.4.0: + resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -8874,9 +8874,9 @@ snapshots: dependencies: tslib: 2.8.1 - '@types/bun@1.3.14': + '@types/bun@1.4.0': dependencies: - bun-types: 1.3.14 + bun-types: 1.4.0 '@types/chai@5.2.3': dependencies: @@ -9523,7 +9523,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.14: + bun-types@1.4.0: dependencies: '@types/node': 26.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d35f877dfd..8d24ddba32 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ catalog: "@effect/vitest": "4.0.0-rc.111" "@nx/devkit": "^23.1.1" "@tsconfig/bun": "^1.0.10" - "@types/bun": "^1.3.14" + "@types/bun": "^1.4.0" "typescript": "^7.0.2" "@vitest/coverage-istanbul": "^4.1.10" "effect": "4.0.0-rc.111" @@ -47,6 +47,8 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-rc.111" - "@supabase/pg-delta@1.0.0-alpha.42" - "@supabase/pg-topo@1.0.0-alpha.5" + - "@types/bun@1.4.0" + - "bun-types@1.4.0" - "effect@4.0.0-rc.111" supportedArchitectures: From d9c74009f319a72b2a2fc63882456e15b3bfe266 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:51:34 +0000 Subject: [PATCH 22/63] chore(api): sync Management API OpenAPI spec (#6288) This PR was automatically created to sync the generated `@supabase/api` package with the latest Management API OpenAPI document. Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. --------- Co-authored-by: jgoux <1443499+jgoux@users.noreply.github.com> Co-authored-by: Julien Goux --- packages/api/src/generated/contracts.ts | 15 +++++++++++++++ packages/api/src/generated/openapi.json | 13 ++++++++++++- packages/api/src/internal/client.unit.test.ts | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 2a41780876..5ab5af98c4 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -10812,6 +10812,21 @@ export const V2GetProjectConfigOutput = Schema.Struct({ id: Schema.String.annotate({ description: "Project ref." }), attributes: Schema.Struct({ database: Schema.Struct({ + major_version: Schema.Number.annotate({ + description: + "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), ssl_enforced: Schema.Boolean.annotate({ description: "Whether the database rejects plaintext connections", }), diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index f6f70837da..e3d168ede2 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -24029,6 +24029,12 @@ "database": { "type": "object", "properties": { + "major_version": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version." + }, "ssl_enforced": { "type": "boolean", "description": "Whether the database rejects plaintext connections" @@ -24222,7 +24228,12 @@ "description": "Postgres parameter overrides. Empty when the project runs entirely on defaults." } }, - "required": ["ssl_enforced", "network_restrictions", "postgres_settings"] + "required": [ + "major_version", + "ssl_enforced", + "network_restrictions", + "postgres_settings" + ] }, "pooler": { "type": "object", diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index cda2b8fd61..893dd619b4 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1071,6 +1071,7 @@ describe("makeSupabaseApiClient", () => { id: "abcdefghijklmnopqrst", attributes: { database: { + major_version: 17, ssl_enforced: true, network_restrictions: { entitlement: "disallowed", @@ -1140,6 +1141,7 @@ describe("makeSupabaseApiClient", () => { ); expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); + expect(result.data.attributes.database.major_version).toBe(17); expect(result.data.attributes.storage.upstream_target).toBe("main"); expect(result.data.attributes.api.db_pool).toBeNull(); }); From 0e86191ef056b5dd2b02367e3a275b1bbcc00890 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:52:05 +0000 Subject: [PATCH 23/63] fix(docker): bump supabase/postgres from 17.6.1.159 to 17.6.1.165 in /apps/cli-go/pkg/config/templates (#6258) Bumps supabase/postgres from 17.6.1.159 to 17.6.1.165. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=supabase/postgres&package-manager=docker&previous-version=17.6.1.159&new-version=17.6.1.165)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- packages/stack/src/ServiceCatalog.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index c18881e4db..3276c1a2ef 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,5 +1,5 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.159 AS pg +FROM supabase/postgres:17.6.1.165 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index ef6f7844bd..32c4cd5eaa 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -108,7 +108,7 @@ export const SERVICE_CATALOG = { postgres: { name: "postgres", configKey: "postgres", - defaultVersion: "17.6.1.159", + defaultVersion: "17.6.1.165", runtimeSupport: "native-preferred", artifact: { docker: { ownership: "supabase", repository: "postgres" }, From 28da80991a6c1c10a55afa48027def75a1c75743 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:52:30 +0000 Subject: [PATCH 24/63] fix(docker): bump the docker-minor group in /apps/cli-go/pkg/config/templates with 4 updates (#6257) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 4 updates: supabase/gotrue, supabase/realtime, supabase/storage-api and supabase/logflare. Updates `supabase/gotrue` from v2.195.0 to v2.196.0 Updates `supabase/realtime` from v2.129.0 to v2.129.3 Updates `supabase/storage-api` from v1.69.11 to v1.70.3 Updates `supabase/logflare` from 1.50.2 to 1.50.4 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 8 ++++---- packages/stack/src/ServiceCatalog.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 3276c1a2ef..f24a2d1043 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -10,10 +10,10 @@ FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor -FROM supabase/gotrue:v2.195.0 AS gotrue -FROM supabase/realtime:v2.129.0 AS realtime -FROM supabase/storage-api:v1.69.11 AS storage -FROM supabase/logflare:1.50.2 AS logflare +FROM supabase/gotrue:v2.196.0 AS gotrue +FROM supabase/realtime:v2.129.3 AS realtime +FROM supabase/storage-api:v1.70.3 AS storage +FROM supabase/logflare:1.50.4 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index 32c4cd5eaa..fb8c5ea740 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -155,7 +155,7 @@ export const SERVICE_CATALOG = { auth: { name: "auth", configKey: "auth", - defaultVersion: "2.195.0", + defaultVersion: "2.196.0", runtimeSupport: "native-preferred", artifact: { docker: { ownership: "supabase", repository: "gotrue", tagPrefix: "v" }, @@ -201,7 +201,7 @@ export const SERVICE_CATALOG = { realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "2.129.0", + defaultVersion: "2.129.3", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" } }, activation: { startup: "eager", activates: [], owns: [] }, @@ -210,7 +210,7 @@ export const SERVICE_CATALOG = { storage: { name: "storage", configKey: "storage", - defaultVersion: "1.69.11", + defaultVersion: "1.70.3", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" } }, activation: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] }, @@ -257,7 +257,7 @@ export const SERVICE_CATALOG = { analytics: { name: "analytics", configKey: "analytics", - defaultVersion: "1.50.2", + defaultVersion: "1.50.4", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "logflare" } }, activation: { startup: "lazy", activates: ["vector"], owns: ["vector"] }, From 803e9ffebb8d1c879d0ee790400c099e6410ceb8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 21 Aug 2026 22:12:40 +0000 Subject: [PATCH 25/63] feat(stack): prepare slim resources on demand (#6250) ## Summary - make the `@supabase/stack` runtime core Effect-native across Node and Bun, with Promise adapters only at the public non-Effect edge - add package-owned `off`, `lazy`, and `eager` preparation policies with dependency closure, bounded concurrency, coalesced work, and deterministic disposal - resolve native slim-service releases through exact manifests and checksums, asynchronous in-process zstd decompression, host compatibility gates, typed post-processing, and atomic cache publication - select one exclusive native or Docker/Podman runtime, persist that concrete selection for managed stacks, and use it consistently for preparation, execution, logs, and cleanup - centralize managed identity, lifecycle documents, sticky port intent, control ownership, stale-owner recovery, and detached supervision in one manager and lifecycle facade - harden process-compose lifecycle transitions with Effect fibers, scopes, semaphores, typed failures, and interruption-safe resource cleanup ## Context Direct and managed stacks now share one service graph, preparation pipeline, port allocator, proxy, and process lifecycle. Direct callers own a scoped in-process handle. Managed callers use a detached supervisor with one durable document and one loopback control owner; CLI handlers delegate to that facade instead of maintaining PID-based liveness or parallel metadata. When mode is omitted, selection prefers a usable Docker or Podman runtime. If neither is usable on a supported host, the first launch selects native mode and disables Docker-only services before port planning or managed state acquisition. Explicit native and Docker choices remain strict, preparation never falls back across modes, and a managed stack pins its selected runtime after ownership is acquired. Changing modes requires deleting and recreating that private managed stack state. Preparation is driven by the service catalog. Eager resources are prepared during stack start, lazy resources use the same activation path as proxy and programmatic callers, and concurrent requests join the same owned work. Native archives are verified before extraction, decompressed through an interruptible callback boundary, validated for the current host, post-processed with checked exit codes, and published from private staging through atomic rename. Docker services resolve one canonical GHCR image and retry only classified transient pull failures. Port allocation owns bound sockets until each runtime consumer takes over. Explicit ports remain exact; automatic managed assignments remain sticky and are coordinated with per-user claims. Managed control uses a deterministic sequence of eight loopback candidates, read-only probes never claim ownership, and mutations fail closed when no unambiguous owner or free candidate exists. Native mode currently supports Postgres, Auth, and PostgREST. Docker-only services remain container-backed. On Linux, Docker Postgres performs only the required image setup as root and then drops to the host UID before touching bind-mounted data. Database bootstrap is a resumable observable one-shot dependency, and cleanup is scoped to exact owned containers, ports, processes, and auto-managed paths. The managed document and identity markers are private unreleased state. This change intentionally implements the current model directly without legacy migration or compatibility adapters, while preserving fail-loud ownership and destructive-cleanup safeguards. --- AGENTS.md | 127 +- .../commands/sso/add/add.integration.test.ts | 6 +- ...gacy-pgdelta-ssl-probe.integration.test.ts | 3 +- .../branches/switch/switch.handler.ts | 25 +- .../functions/dev/functions-dev-runtime.ts | 12 +- .../src/next/commands/logs/logs.e2e.test.ts | 111 -- .../service-version-overrides.unit.test.ts | 18 +- .../src/next/commands/start/start.command.ts | 19 +- .../src/next/commands/start/start.e2e.test.ts | 70 - .../src/next/commands/start/start.handler.ts | 3 +- .../commands/start/start.integration.test.ts | 2 +- .../next/commands/status/status.e2e.test.ts | 57 - .../next/commands/status/status.handler.ts | 12 +- .../src/next/commands/stop/stop.e2e.test.ts | 88 -- .../commands/stop/stop.integration.test.ts | 15 +- .../next/commands/update/update.handler.ts | 7 +- apps/cli/src/next/config/stack-config.ts | 26 +- .../src/next/config/stack-config.unit.test.ts | 34 +- .../runtime/stack-e2e-cleanup.unit.test.ts | 39 + .../error-actionability-coverage.unit.test.ts | 2 +- .../shared/telemetry/error-actionability.ts | 7 + apps/cli/tests/helpers/running-stack.ts | 7 +- apps/cli/tests/helpers/stack-e2e-cleanup.ts | 6 +- ...7-simplified-managed-stack-architecture.md | 30 +- packages/process-compose/docs/architecture.md | 23 +- packages/process-compose/src/HealthProbe.ts | 29 +- .../src/HealthProbe.unit.test.ts | 35 +- packages/process-compose/src/LogBuffer.ts | 11 +- .../src/LogBuffer.unit.test.ts | 61 +- .../src/Orchestrator.integration.test.ts | 222 +-- packages/process-compose/src/Orchestrator.ts | 117 +- .../src/Orchestrator.unit.test.ts | 75 +- .../process-compose/src/RestartDecision.ts | 8 +- .../src/RestartDecision.unit.test.ts | 18 +- .../process-compose/src/ServiceTransition.ts | 193 +-- .../src/ServiceTransition.unit.test.ts | 22 +- .../src/SupervisorRuntime.unit.test.ts | 96 ++ .../process-compose/src/errors.unit.test.ts | 9 +- .../process-compose/src/supervisor-runtime.ts | 414 +++--- .../process-compose/tests/helpers/mocks.ts | 8 +- packages/stack/README.md | 11 +- packages/stack/docs/architecture.md | 122 +- packages/stack/src/ApiProxy.ts | 8 +- packages/stack/src/ApiProxy.unit.test.ts | 8 +- .../src/BinaryResolver.integration.test.ts | 976 +++++++++++--- packages/stack/src/BinaryResolver.ts | 902 ++++++++++--- .../stack/src/BinaryResolver.unit.test.ts | 113 +- .../src/ContainerRuntime.integration.test.ts | 106 ++ packages/stack/src/ContainerRuntime.ts | 100 ++ packages/stack/src/DaemonProtocol.ts | 2 + .../src/DaemonServer.integration.test.ts | 13 +- packages/stack/src/DaemonServer.ts | 67 +- .../HttpTransportClient.integration.test.ts | 97 ++ packages/stack/src/HttpTransportClient.ts | 11 +- packages/stack/src/LocalStack.ts | 563 +++++--- packages/stack/src/Platform.ts | 40 +- packages/stack/src/Platform.unit.test.ts | 73 +- .../src/PortAllocator.integration.test.ts | 310 +++-- packages/stack/src/PortAllocator.ts | 771 ++++++++--- packages/stack/src/PortAllocator.unit.test.ts | 195 --- .../stack/src/RemoteStack.integration.test.ts | 170 ++- packages/stack/src/RemoteStack.ts | 182 ++- packages/stack/src/ServiceActivation.ts | 7 +- .../stack/src/ServiceActivation.unit.test.ts | 3 +- packages/stack/src/ServiceCatalog.ts | 292 ++-- packages/stack/src/ServicePorts.ts | 8 +- packages/stack/src/Stack.ts | 28 +- packages/stack/src/Stack.unit.test.ts | 1189 ++++++++++++++--- packages/stack/src/StackBuilder.ts | 298 +++-- packages/stack/src/StackBuilder.unit.test.ts | 317 ++--- packages/stack/src/StackConfig.ts | 17 +- .../StackConfigResolver.policy.unit.test.ts | 125 ++ packages/stack/src/StackConfigResolver.ts | 981 +++++++++----- packages/stack/src/StackPreparation.ts | 465 ++++--- packages/stack/src/bun.ts | 33 +- packages/stack/src/cleanup.ts | 101 +- packages/stack/src/cleanup.unit.test.ts | 72 + .../stack/src/createStack.integration.test.ts | 322 +++-- packages/stack/src/createStack.ts | 361 +++-- packages/stack/src/createStack.unit.test.ts | 297 ++-- packages/stack/src/discovery.ts | 32 +- packages/stack/src/effect-bun.ts | 2 +- packages/stack/src/effect-node.ts | 2 +- packages/stack/src/effect.ts | 29 +- packages/stack/src/errors.ts | 125 +- packages/stack/src/functions.unit.test.ts | 80 +- packages/stack/src/index.ts | 7 +- packages/stack/src/layers.ts | 4 +- .../src/managed-control.integration.test.ts | 99 +- .../managed-environment.integration.test.ts | 6 +- ...aged-manager-lifecycle.integration.test.ts | 139 +- .../managed-manager-ports.integration.test.ts | 92 +- ...naged-manager-projects.integration.test.ts | 27 +- ...naged-manager-recovery.integration.test.ts | 68 +- packages/stack/src/managed-paths.unit.test.ts | 200 +-- .../src/managed-store.integration.test.ts | 68 +- packages/stack/src/managed.ts | 3 +- .../managed/atomic-claim.integration.test.ts | 124 ++ packages/stack/src/managed/atomic-claim.ts | 175 ++- packages/stack/src/managed/control.ts | 23 +- packages/stack/src/managed/document.ts | 82 +- packages/stack/src/managed/error-code.ts | 12 - packages/stack/src/managed/failure.ts | 51 +- packages/stack/src/managed/git-identity.ts | 52 +- .../stack/src/managed/git.integration.test.ts | 109 +- packages/stack/src/managed/git.ts | 381 +++--- packages/stack/src/managed/identity.ts | 507 ++++--- packages/stack/src/managed/ids.ts | 22 +- packages/stack/src/managed/lifecycle.ts | 43 +- packages/stack/src/managed/manager.ts | 306 +++-- .../stack/src/managed/manager.unit.test.ts | 48 + packages/stack/src/managed/model.ts | 7 +- packages/stack/src/managed/paths.ts | 149 ++- packages/stack/src/managed/store.ts | 193 +-- packages/stack/src/node.ts | 33 +- .../src/platform-bun.integration.test.ts | 45 + packages/stack/src/platform-bun.ts | 55 +- .../src/platform-node.integration.test.ts | 182 +++ packages/stack/src/platform-node.ts | 343 +++-- packages/stack/src/prefetch.ts | 25 +- packages/stack/src/prefetch.unit.test.ts | 524 +++++--- packages/stack/src/services/analytics.ts | 20 +- packages/stack/src/services/auth.ts | 11 +- packages/stack/src/services/docker-cleanup.ts | 11 +- packages/stack/src/services/edge-runtime.ts | 89 +- packages/stack/src/services/imgproxy.ts | 10 +- packages/stack/src/services/mailpit.ts | 10 +- packages/stack/src/services/pgmeta.ts | 9 +- packages/stack/src/services/pooler.ts | 9 +- packages/stack/src/services/postgres-init.ts | 166 ++- packages/stack/src/services/postgres.ts | 191 ++- packages/stack/src/services/postgrest.ts | 11 +- packages/stack/src/services/realtime.ts | 9 +- packages/stack/src/services/service-utils.ts | 38 +- .../stack/src/services/services.unit.test.ts | 384 ++---- packages/stack/src/services/storage.ts | 9 +- packages/stack/src/services/studio.ts | 9 +- packages/stack/src/services/vector.ts | 80 +- .../stack/src/services/vector.unit.test.ts | 120 ++ packages/stack/src/stackHandle.ts | 55 + .../stack/src/supervisor.integration.test.ts | 564 ++++++-- packages/stack/src/supervisor.ts | 334 +++-- packages/stack/src/terminateChild.ts | 98 +- .../stack/src/terminateChild.unit.test.ts | 23 +- packages/stack/src/version-plan.unit.test.ts | 24 +- packages/stack/src/versions.ts | 58 +- packages/stack/src/versions.unit.test.ts | 52 +- .../tests/createStack-docker.e2e.test.ts | 65 +- .../tests/createStack-native.e2e.test.ts | 104 ++ packages/stack/tests/createStack.e2e.test.ts | 89 +- packages/stack/tests/global-setup.ts | 2 +- packages/stack/tests/helpers/e2e.ts | 47 +- packages/stack/tests/helpers/file-watch.ts | 37 + .../stack/tests/helpers/managed-manager.ts | 24 +- packages/stack/tests/helpers/mocks.ts | 14 +- .../stack/tests/helpers/port-lease-child.ts | 18 + packages/stack/tests/helpers/spawn-stack.ts | 114 -- .../tests/helpers/spawn-stack.unit.test.ts | 132 -- packages/stack/tests/helpers/stack-ports.ts | 3 +- .../stack/tests/helpers/standalone-stack.ts | 111 -- .../stack/tests/helpers/supervisor-child.ts | 110 +- packages/stack/tests/helpers/warmup.ts | 25 +- .../stack/tests/helpers/warmup.unit.test.ts | 40 +- .../stack/tests/parallelStacks.e2e.test.ts | 56 - .../tests/postgresDataPersistence.e2e.test.ts | 6 +- 165 files changed, 12746 insertions(+), 6942 deletions(-) delete mode 100644 apps/cli/src/next/commands/logs/logs.e2e.test.ts delete mode 100644 apps/cli/src/next/commands/start/start.e2e.test.ts delete mode 100644 apps/cli/src/next/commands/status/status.e2e.test.ts delete mode 100644 apps/cli/src/next/commands/stop/stop.e2e.test.ts create mode 100644 packages/stack/src/ContainerRuntime.integration.test.ts create mode 100644 packages/stack/src/ContainerRuntime.ts create mode 100644 packages/stack/src/HttpTransportClient.integration.test.ts delete mode 100644 packages/stack/src/PortAllocator.unit.test.ts create mode 100644 packages/stack/src/StackConfigResolver.policy.unit.test.ts create mode 100644 packages/stack/src/cleanup.unit.test.ts create mode 100644 packages/stack/src/managed/atomic-claim.integration.test.ts delete mode 100644 packages/stack/src/managed/error-code.ts create mode 100644 packages/stack/src/managed/manager.unit.test.ts create mode 100644 packages/stack/src/platform-bun.integration.test.ts create mode 100644 packages/stack/src/platform-node.integration.test.ts create mode 100644 packages/stack/src/services/vector.unit.test.ts create mode 100644 packages/stack/src/stackHandle.ts create mode 100644 packages/stack/tests/createStack-native.e2e.test.ts create mode 100644 packages/stack/tests/helpers/file-watch.ts create mode 100644 packages/stack/tests/helpers/port-lease-child.ts delete mode 100644 packages/stack/tests/helpers/spawn-stack.ts delete mode 100644 packages/stack/tests/helpers/spawn-stack.unit.test.ts delete mode 100644 packages/stack/tests/helpers/standalone-stack.ts delete mode 100644 packages/stack/tests/parallelStacks.e2e.test.ts diff --git a/AGENTS.md b/AGENTS.md index 878e6ab5be..741d3be1c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,128 @@ Write new runtime code Effect-native from the start; do not build a sync or Prom - Model failures as `Data.TaggedError` classes with typed error channels, dependencies as services provided through `Layer`, retries/polling as `Schedule`s, and resource lifecycles with scopes and interruption-safe masks — never `Atomics.wait`, ad-hoc `setTimeout` loops, or manual try/finally resource juggling in core code. - Expose Promise-based facades only at the outermost package edge (public entrypoints for non-Effect consumers), acquired asynchronously — never inside the core. -- A small leaf primitive with no Effect semantics of its own (a pure function, a single-syscall fs helper) may stay plain async and be wrapped at its call boundary; everything with failure modes, retries, resources, or concurrency belongs in Effect. +- Internal helpers must not return Promises. Use the Effect platform services or + `Effect.callback` for filesystem, process, network, and other host APIs. A + foreign library operation that exposes only a Promise may be wrapped once + with `Effect.tryPromise` at the leaf boundary, with its cancellation signal + and failure mapped into Effect. Any other exception needs a concrete reason + why the operation is impossible to express with Effect. + +### Effect evaluation and state + +An `Effect` is a reusable description and may be evaluated more than once. + +- Create per-execution mutable state inside `Effect.suspend`, `Effect.gen`, or a + scoped acquisition. +- Never allocate mutable ownership state while constructing an Effect and then + close over it. Re-evaluating that Effect would share state across executions. +- Keep `Effect.sync` total. If its thunk can throw, use `Effect.try` and map the + failure into the typed error channel. + +### Foreign callback boundaries + +An `Effect.callback` adapter owns the complete lifecycle of the foreign +operation. + +- Register success, error, abort, close, and cancellation listeners before + starting the operation. +- Guarantee at-most-once resumption. +- Return a cancellation effect that removes every owned listener and closes or + destroys the exact owned resource. +- Pass the Effect cancellation signal to foreign Promise APIs whenever they + support `AbortSignal`. + +### Service requirements + +Let Effect service requirements remain visible until the composition boundary. + +- Propagate `FileSystem`, `Scope`, process, network, and other requirements + through the Effect type. +- Provide services through layers or explicit `Effect.provide` at the owning + boundary. +- Do not hide missing services with casts, nested `runSync`/`runPromise`, + globals, or ad-hoc synchronous adapters. + +### Structured concurrency and coordination + +Use Effect's concurrency primitives according to the ownership relationship they +represent: + +- Prefer `Effect.forkChild`; use `forkScoped`/`forkIn` when a fiber belongs to a + longer-lived scope. `forkDetach` is exceptional and must document why the + work intentionally outlives its caller and how completion is observed. +- Use `Deferred` for one-shot handoff, `Latch` for a reusable open/closed gate, + `Semaphore` for bounded access or lifecycle serialization, `Queue` for + producer/consumer work, and `PubSub` for broadcast. Do not replace these with + mutable waiter arrays, Promise gates, booleans plus polling, or propagation + sleeps. +- Use the `concurrency` option on `Effect.all`/`Effect.forEach` for simple caps; + use a `Semaphore` when permits span a larger critical section. +- Let `Effect.race`, `raceFirst`, and concurrent combinators interrupt their + losing or sibling fibers. Do not hand-roll cancellation through shared flags. +- Own resources with `Scope`, `acquireRelease`, or scoped layers. Restrict + `uninterruptibleMask` to the acquisition-to-registration handoff and keep the + actual blocking acquisition interruptible with `restore`. +- Use `Schedule` for retry and unavoidable polling policy. Prefer observable + signals (`Deferred`, streams, filesystem/process events) whenever the foreign + API exposes them. + +### Shared initialization and teardown + +Concurrent callers must join one owned operation rather than merely observe a +boolean. + +- Represent single-flight initialization and teardown with a cached Effect, a + shared Fiber, or a `Deferred>`. +- The owning fiber performs the work; every caller awaits the same result. +- Interrupting one waiter must not cancel shared teardown or leave later + callers believing cleanup has completed. + +### Typed failures and tagged values + +Expected failures in Effect code must be represented in the typed error channel. + +- Use `Data.TaggedError` for domain failures and return them with `Effect.fail`. +- Do not `throw` expected validation, parsing, protocol, filesystem, or lifecycle errors inside Effect programs. +- Use `Effect.try`, `Effect.tryPromise`, or callback adapters only at foreign boundaries, and map failures into a declared domain error. +- Reserve defects (`Effect.die` or an uncaught throw) for genuinely impossible internal invariants and programmer bugs. +- Standalone process entrypoints and public non-Effect adapters may throw or reject after translating the typed Effect failure at the outer boundary. + +### Causes and recovery + +Use the narrowest error operator that matches the intended recovery policy. + +- `Effect.catch`, `catchTag`, and `catchTags` handle expected typed failures. +- Use `Effect.catchCause` only when recovery intentionally needs to observe + defects or interruption. +- When inspecting a full `Cause`, recover only the explicitly recognized + condition and return every other cause unchanged with `Effect.failCause`. +- Never squash an arbitrary cause and convert it into a domain error; that can + erase interruption and defects. +- Avoid `Effect.orDie` and `Layer.orDie` for operational failures that callers + may need to classify, retry, or report. + +Do not inspect Effect runtime representations through fields such as `._tag`. + +- Use the library helper for Effect data types: `Exit.isSuccess`, `Exit.isFailure`, `Option.isSome`, `Option.isNone`, `Result.isSuccess`, `Result.isFailure`, `Cause.isTimeoutError`, and similar APIs. +- Use `Effect.catchTag`, `Effect.catchTags`, `Effect.tapErrorTag`, and related operators for typed Effect errors. +- Use `Predicate.isTagged` or a named domain predicate when narrowing one variant of a tagged domain union. +- Use `Match.tag`, `Match.valueTags`, or another exhaustive `Match` helper when behavior depends on multiple variants of a domain union. +- Direct `_tag` access is appropriate only when defining schemas/types, constructing or serializing tagged values, or implementing a genuinely dynamic boundary that cannot know the variants statically. +- Tests follow the same rules; assertions should use public helpers rather than inspecting Effect internals. + +Prefer exhaustive matching for domain state machines and event handling. A new union member should produce a type error at every behaviorally relevant match rather than silently falling through a `default` branch. + +### Schema decoding and encoding + +Inside Effect code, compose schemas through their Effect APIs: + +- Prefer `Schema.decodeUnknownEffect`, `Schema.decodeEffect`, `Schema.encodeEffect`, and their typed error channels. +- Map `SchemaError` into the domain error expected by the consuming operation. +- Express additional validation with `Effect.filterOrFail`, `Effect.flatMap`, or `Effect.fail` instead of throwing inside a decoding callback. +- Avoid `decodeUnknownSync` and `encodeUnknownSync` in Effect-native code. They execute through the synchronous runtime and report invalid input by throwing, which can turn a recoverable parse failure into a defect. +- Sync schema operations are not asynchronous I/O and are not inherently “blocking” in that sense. The reason to prefer the Effect variants is typed failure handling, dependency propagation, interruption semantics, and support for effectful schema transformations—not to move ordinary CPU validation onto another thread. +- Sync codecs are acceptable at an explicitly synchronous outer boundary when the schema is guaranteed to be service-free and the caller intentionally accepts a thrown exception. Otherwise, keep decoding and encoding in Effect. ## Code Quality @@ -167,9 +288,9 @@ See `apps/cli/src/commands/login/` as the canonical example. ### Flake-resistant tests -Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; bound waits by wall-clock deadline, never attempt counts; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case. +Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case. -During review, arbitrary sleeps, wall-clock completion assertions, attempt-count retry budgets, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented. +During review, arbitrary sleeps, wall-clock completion assertions, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented. ### Integration test pattern diff --git a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts index c0e5e3bf18..753d33fbd0 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts @@ -805,10 +805,10 @@ describe("legacy sso add integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); // URL validation runs against the consumed token, not the parsed - // Option — `--name-id-format` is not a valid HTTPS URL, so the - // command fails before any request, like Go. + // Option — it is not a valid HTTPS URL, so the command fails before + // any request, like Go. URL implementations do not consistently + // include the rejected input in their exception text. expect(dump).toContain("LegacySsoAddMetadataFileError"); - expect(dump).toContain("--name-id-format"); expect(dump).toContain("Use --skip-url-validation to suppress this error"); } expect(api.requests.some((r) => r.method === "POST")).toBe(false); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts index 5f94b66971..03d014a8e6 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts @@ -35,7 +35,7 @@ async function withClosingServer(run: (port: number) => Promise): Promise< } describe("legacyPgDeltaSslProbeLayer", () => { - it.live("fails promptly when the socket closes before an SSL response byte", () => + it.live("fails promptly when the server disconnects before an SSL response byte", () => Effect.tryPromise({ try: () => withClosingServer((port) => @@ -53,7 +53,6 @@ describe("legacyPgDeltaSslProbeLayer", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain(LegacyPgDeltaSslProbeError.name); - expect(String(exit.cause)).toContain("closed before the server responded"); } }).pipe( Effect.provide(legacyPgDeltaSslProbeLayer), diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index fbf53b6bc0..d7fca8098a 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -145,19 +145,16 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { // TODO: run `supabase pull` against the new branch before restarting the stack // so the local config reflects the branch's migrations and seed state. // `pull` does not exist yet. - const launchConfig = - stackCheck.value.launch === undefined - ? toStartStackConfig([], "auto") - : withServiceVersions( - toStartStackConfig( - stackCheck.value.launch.excludedServices?.filter( - (service): service is ExcludedStackService => - excludedStackServices.some((candidate) => candidate === service), - ) ?? [], - stackCheck.value.launch.mode, - ), - stackCheck.value.launch.versions, - ); + const launch = stackCheck.value.launch; + const launchConfig = withServiceVersions( + toStartStackConfig( + launch.excludedServices?.filter((service): service is ExcludedStackService => + excludedStackServices.some((candidate) => candidate === service), + ) ?? [], + launch.mode, + ), + launch.versions, + ); const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); const stackLayer = yield* daemonLayer({ @@ -166,7 +163,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { projectDir: projectHome.projectRoot, name: stackName, portIntents: managedPortIntents(launchConfig, loadedProjectConfig ?? undefined), - ...(stackCheck.value.launch !== undefined && { launch: stackCheck.value.launch }), + launch, ...launchConfig, }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 680c67417e..1649b9b5d8 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -54,10 +54,13 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); - const stackConfig = withServiceVersions( - toStartStackConfig([], "auto"), - serviceVersionContext.runtimeVersions, - ); + const stackConfig = { + ...withServiceVersions(toStartStackConfig([], "docker"), serviceVersionContext.runtimeVersions), + // Functions dev explicitly requires Edge Runtime even when the project + // config supplies only schema defaults. Request Docker explicitly so the + // managed daemon validates container availability before persisting state. + servicePolicies: { "edge-runtime": "eager" as const }, + }; const stackLayer = yield* daemonLayer({ cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, @@ -65,7 +68,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio name: opts.stack, edgeRuntime: opts.edgeRuntime, launch: { - mode: "auto", versions: serviceVersionContext.pinnedBaseline, excludedServices: [], }, diff --git a/apps/cli/src/next/commands/logs/logs.e2e.test.ts b/apps/cli/src/next/commands/logs/logs.e2e.test.ts deleted file mode 100644 index 561f112811..0000000000 --- a/apps/cli/src/next/commands/logs/logs.e2e.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - makeTempHome, - makeTempStackProject, - runSupabase, - spawnSupabase, -} from "../../../../tests/helpers/cli.ts"; - -const LOGS_TIMEOUT_MS = 30_000; -const LOGS_IDLE_WINDOW_MS = 500; -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -function extractApiUrl(output: string): string { - const match = output.match(/API URL:\s+(http:\/\/\S+)/); - if (match?.[1] == null) { - throw new Error(`Could not find API URL in output:\n${output}`); - } - return match[1]; -} - -async function triggerAuthLog(apiUrl: string): Promise { - const response = await fetch(`${apiUrl}/auth/v1/signup`); - expect(response.status).toBe(405); -} - -async function waitForMatches( - proc: ReturnType, - pattern: RegExp, - count: number, - timeoutMs = LOGS_TIMEOUT_MS, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const matches = proc - .stdout() - .match(new RegExp(pattern.source, pattern.flags + (pattern.flags.includes("g") ? "" : "g"))); - if ((matches?.length ?? 0) >= count) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Timed out waiting for ${count} matches of ${pattern}`); -} - -describe("supabase logs", () => { - test( - "prints buffered history on attach and keeps following after an idle period", - { timeout: LOGS_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-logs-e2e-"); - let logsProc: ReturnType | undefined; - - try { - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: LOGS_TIMEOUT_MS, - }); - expect(startResult.exitCode).toBe(0); - const apiUrl = extractApiUrl(startResult.stdout); - - await triggerAuthLog(apiUrl); - - logsProc = spawnSupabase(["logs"], { - cwd: project.dir, - home: home.dir, - cleanupProcessGroupOnClose: false, - }); - - await waitForMatches(logsProc, /\[auth\].*"path":"\/signup"/, 1); - - await new Promise((resolve) => setTimeout(resolve, LOGS_IDLE_WINDOW_MS)); - await triggerAuthLog(apiUrl); - await waitForMatches(logsProc, /\[auth\].*"path":"\/signup"/, 2); - - logsProc.kill("SIGTERM"); - - const result = await logsProc.waitForExit(); - logsProc = undefined; - - expect(result.stderr).not.toContain("ECONNRESET"); - expect(result.stderr).not.toContain("The socket connection was closed unexpectedly"); - } finally { - logsProc?.kill("SIGTERM"); - await logsProc?.waitForExit().catch(() => {}); - } - }, - ); -}); diff --git a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts index dc23a41bac..65658df4de 100644 --- a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts +++ b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts @@ -11,15 +11,15 @@ import { } from "../../config/service-version-resolution.ts"; describe("service version overrides", () => { - test("parses and normalizes repeated flag overrides", async () => { + test("canonicalizes repeated flag overrides to published service tags", async () => { await expect( Effect.runPromise( parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]), ), ).resolves.toEqual({ - postgrest: "14.5", + postgrest: "v14.5", mailpit: "v1.30.2", - auth: "2.180.0", + auth: "v2.180.0", }); }); @@ -27,8 +27,8 @@ describe("service version overrides", () => { const candidateBaseline = { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", + postgrest: "v14.5", + auth: "v2.187.0", }; const layer = Layer.mergeAll( @@ -68,13 +68,13 @@ describe("service version overrides", () => { runtimeVersions: { ...candidateBaseline, postgres: "17.4.1.045", - auth: "2.170.0", - storage: "1.40.0", + auth: "v2.170.0", + storage: "v1.40.0", }, activeOverrides: [ { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "2.170.0", source: "flag" }, - { service: "storage", version: "1.40.0", source: "local" }, + { service: "auth", version: "v2.170.0", source: "flag" }, + { service: "storage", version: "v1.40.0", source: "local" }, ], availableUpdates: [], updateFingerprint: undefined, diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index fbcd3043b6..bb22890b5b 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Context } from "effect"; +import { Effect, Layer, Context, Option } from "effect"; import { loadProjectConfig } from "@supabase/config"; import { DEFAULT_MANAGED_STACK_NAME, @@ -79,14 +79,15 @@ export const serviceVersionFlag = Flag.string("service-version").pipe( const modeFlag = Flag.choice("mode", startModes).pipe( Flag.withDescription( - 'Stack startup mode. "auto" prefers native binaries and falls back to Docker, "native" requires native-compatible services, and "docker" forces Docker for all services.', + 'Stack startup mode. "native" requires native-compatible services and "docker" requires a usable Docker or Podman runtime.', ), - Flag.withDefault("auto" as StartMode), + Flag.optional, + Flag.map(Option.getOrUndefined), ); interface StartVersionStateShape { readonly launch: { - readonly mode: StartMode; + readonly mode?: StartMode; readonly versions: Readonly>; readonly excludedServices: ReadonlyArray; }; @@ -124,7 +125,7 @@ export type StartFlags = CliCommand.Command.Config.Infer; export const startCommand = Command.make("start", flags).pipe( Command.withDescription( "Start the local Supabase development stack.\n\n" + - "Starts the full local Supabase stack. Use --mode auto (default) to prefer native binaries and fall back to Docker, --mode native to require native-compatible services, or --mode docker to force Docker-backed startup.\n\n" + + "Starts the full local Supabase stack when Docker or Podman is usable; otherwise a supported host starts the native-capable service set. Use --mode to require one explicitly.\n\n" + "Named CLI stacks persist managed runtime state under the Supabase home directory. Use --exclude to skip optional services. Use --detach to run in the background.", ), Command.withShortDescription("Start local Supabase stack"), @@ -196,8 +197,9 @@ export const startCommand = Command.make("start", flags).pipe( if (deprecationWarning !== undefined) { yield* output.warn(deprecationWarning); } + const effectiveMode = flags.mode ?? existingSummary?.launch.mode; const baseStackConfig = withServiceVersions( - toStartStackConfig(flags.exclude, flags.mode), + toStartStackConfig(flags.exclude, effectiveMode), serviceVersionContext.runtimeVersions, ); const stackConfig = { @@ -219,7 +221,7 @@ export const startCommand = Command.make("start", flags).pipe( portDocument: portIntents, }); const launch = { - mode: flags.mode, + ...(flags.mode === undefined ? {} : { mode: flags.mode }), versions: serviceVersionContext.pinnedBaseline, excludedServices: flags.exclude, ...(existingSummary?.lastNotifiedUpdateFingerprint === undefined @@ -242,12 +244,11 @@ export const startCommand = Command.make("start", flags).pipe( cwd: runtimeInfo.cwd, name: flags.stack, }); - return { stackLayer, startVersionState: StartVersionState.of({ launch: { - mode: flags.mode, + mode: summary.launch.mode, versions: serviceVersionContext.pinnedBaseline, excludedServices: flags.exclude, }, diff --git a/apps/cli/src/next/commands/start/start.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts deleted file mode 100644 index 0b94ce743e..0000000000 --- a/apps/cli/src/next/commands/start/start.e2e.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const DETACHED_START_TIMEOUT_MS = 30_000; -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -describe("supabase start", () => { - test( - "starts in detached mode and prints connection info", - { timeout: DETACHED_START_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-start-e2e-"); - const { stdout, stderr, exitCode } = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DETACHED_START_TIMEOUT_MS, - }); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0); - expect(stdout).toContain("Local Supabase started"); - expect(stdout).toContain("API URL:"); - expect(stdout).toContain("DB URL:"); - }, - ); - - test( - "reattaches when detached start is already running", - { timeout: DETACHED_START_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-start-e2e-"); - const first = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DETACHED_START_TIMEOUT_MS, - }); - expect(first.exitCode, `stdout:\n${first.stdout}\nstderr:\n${first.stderr}`).toBe(0); - - const second = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DETACHED_START_TIMEOUT_MS, - }); - expect(second.exitCode, `stdout:\n${second.stdout}\nstderr:\n${second.stderr}`).toBe(0); - expect(second.stdout).toContain("Start local Supabase stack"); - expect(second.stdout).toContain("Local Supabase started"); - }, - ); -}); diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index deafa672bf..2a388237f7 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -50,7 +50,6 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { yield* updateManagedLaunch({ ...lifecycleInput, launch: { - mode: launch.mode, versions: launch.versions, excludedServices: launch.excludedServices, lastNotifiedUpdateFingerprint: serviceVersionContext.updateFingerprint, @@ -68,7 +67,7 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { } yield* analytics.capture("cli_stack_started", { - mode: flags.mode, + mode: launch.mode, detach: flags.detach, stack: flags.stack, }); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index e98b31af2a..920bfba263 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -80,7 +80,7 @@ describe("start handler", () => { ); return start({ stack: fixture.stackName, - mode: "auto", + mode: "docker", exclude: [], serviceVersion: [], detach: false, diff --git a/apps/cli/src/next/commands/status/status.e2e.test.ts b/apps/cli/src/next/commands/status/status.e2e.test.ts deleted file mode 100644 index fba5693eeb..0000000000 --- a/apps/cli/src/next/commands/status/status.e2e.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const STATUS_TIMEOUT_MS = 30_000; -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -describe("supabase status", () => { - test( - "shows connection info and service states for the current project", - { timeout: STATUS_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-status-e2e-"); - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: STATUS_TIMEOUT_MS, - }); - expect(startResult.exitCode).toBe(0); - - const result = await runSupabase(["status"], { cwd: project.dir, home: home.dir }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Show local Supabase stack status"); - expect(result.stdout).toContain("Local Supabase stack is running."); - expect(result.stdout).toContain("API URL:"); - expect(result.stdout).toContain("DB URL:"); - expect(result.stdout).toContain("Publishable key:"); - expect(result.stdout).toContain("Secret key:"); - expect(result.stdout).toContain("auth:"); - expect(result.stdout).toContain("postgres:"); - expect(result.stdout).not.toContain("Stack status"); - expect(result.stdout).not.toContain("(running) -"); - }, - ); -}); diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index 546c89b549..db446245f0 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -39,13 +39,11 @@ const resolveConfiguredSummary = Effect.fnUntraced(function* (input: { }) { const current = yield* resolveStackSummary(input); const loaded = yield* loadProjectConfig(input.projectDir); - const excluded = (current.launch?.excludedServices ?? []).filter(isExcludedStackService); + const excluded = (current.launch.excludedServices ?? []).filter(isExcludedStackService); + const mode = current.launch.mode; return yield* resolveStackSummary({ ...input, - portDocument: managedPortIntents( - toStartStackConfig(excluded, current.launch?.mode ?? "auto"), - loaded ?? undefined, - ), + portDocument: managedPortIntents(toStartStackConfig(excluded, mode), loaded ?? undefined), }); }); @@ -97,7 +95,7 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), ); - if (layer._tag === "None") { + if (Option.isNone(layer)) { const summary = yield* resolveConfiguredSummary({ cacheRoot: cliConfig.supabaseHome, projectDir: projectHome.projectRoot, @@ -108,7 +106,7 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), ); - if (summary._tag === "None") { + if (Option.isNone(summary)) { const message = "No local Supabase stack is running for this project."; if (output.format === "text") { yield* output.outro(message); diff --git a/apps/cli/src/next/commands/stop/stop.e2e.test.ts b/apps/cli/src/next/commands/stop/stop.e2e.test.ts deleted file mode 100644 index c235392a62..0000000000 --- a/apps/cli/src/next/commands/stop/stop.e2e.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; -const STOP_STACK_TIMEOUT_MS = 30_000; - -function managedStackDir(homeDir: string): string { - const stacksRoot = join(homeDir, "managed", "stacks"); - const stackIds = existsSync(stacksRoot) - ? readdirSync(stacksRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - : []; - expect(stackIds).toHaveLength(1); - return join(stacksRoot, stackIds[0]!); -} - -describe("supabase stop", () => { - test( - "preserves the persisted stack folder by default", - { timeout: STOP_STACK_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-stop-e2e-"); - - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - }); - expect(startResult.exitCode).toBe(0); - const stackDir = managedStackDir(home.dir); - - const stopResult = await runSupabase(["stop"], { cwd: project.dir, home: home.dir }); - expect(stopResult.exitCode).toBe(0); - expect(existsSync(stackDir)).toBe(true); - expect(existsSync(join(stackDir, "stack.json"))).toBe(true); - }, - ); - - test( - "deletes the persisted stack folder with --no-backup", - { timeout: STOP_STACK_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-stop-e2e-"); - - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - }); - expect(startResult.exitCode).toBe(0); - const stackDir = managedStackDir(home.dir); - - const stopResult = await runSupabase(["stop", "--no-backup"], { - cwd: project.dir, - home: home.dir, - }); - expect( - stopResult.exitCode, - `stdout:\n${stopResult.stdout}\n\nstderr:\n${stopResult.stderr}`, - ).toBe(0); - expect(existsSync(stackDir)).toBe(false); - }, - ); -}); diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index 8adb71a16b..2e4f471ba7 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer } from "effect"; import { existsSync } from "node:fs"; import { stop } from "./stop.handler.ts"; -import { managedStackDocumentPath } from "@supabase/stack/managed"; +import { managedStackDocumentPathEffect } from "@supabase/stack/managed"; import { mockOutput, mockProjectLinkState } from "../../../../tests/helpers/mocks.ts"; import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts"; @@ -48,12 +48,14 @@ describe("stop handler", () => { BunServices.layer, ); return stop({ stack: fixture.stackName, noBackup: true }).pipe( - Effect.provide(layer), Effect.tap( - Effect.sync(() => { - expect(existsSync(managedStackDocumentPath(fixture.stateRoot, fixture.stackId))).toBe( - false, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const documentPath = yield* managedStackDocumentPathEffect( + fixture.stateRoot, + fixture.stackId, ); + expect(yield* fs.exists(documentPath)).toBe(false); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", @@ -62,6 +64,7 @@ describe("stop handler", () => { ); }), ), + Effect.provide(layer), Effect.ensuring(Effect.promise(() => fixture.dispose())), ); }), diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index c5ad206314..3ceaafff76 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -103,19 +103,14 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { ); if (Option.isSome(existingSummary)) { - const persistedLaunch = existingSummary.value.launch ?? { - mode: "auto" as const, - excludedServices: [] as const, - }; yield* updateManagedLaunch({ cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, workspacePath: projectHome.projectRoot, stackName: flags.stack, launch: { - mode: persistedLaunch.mode, versions: serviceVersionContext.candidateBaseline, - excludedServices: persistedLaunch.excludedServices, + excludedServices: existingSummary.value.launch.excludedServices ?? [], ...(existingSummary.value.lastNotifiedUpdateFingerprint === undefined ? {} : { diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 8cc9880552..6b51a4d163 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -17,26 +17,26 @@ export const excludedStackServices = [ export type ExcludedStackService = (typeof excludedStackServices)[number]; export const isExcludedStackService = (value: string): value is ExcludedStackService => excludedStackServices.some((candidate) => candidate === value); -export const startModes = ["native", "auto", "docker"] as const; +export const startModes = ["native", "docker"] as const; export type StartMode = (typeof startModes)[number]; export function toStartStackConfig( exclude: ReadonlyArray, - mode: StartMode, + mode?: StartMode, ): StackConfig { const excluded = new Set(exclude); + const native = mode === "native"; return { - mode, - startupMode: "lazy", - realtime: excluded.has("realtime") ? false : {}, - storage: excluded.has("storage") ? false : {}, - imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {}, - mailpit: excluded.has("mailpit") ? false : {}, - pgmeta: excluded.has("pgmeta") ? false : {}, - studio: excluded.has("studio") || excluded.has("pgmeta") ? false : {}, - analytics: excluded.has("analytics") ? false : {}, - vector: excluded.has("vector") || excluded.has("analytics") ? false : {}, - pooler: excluded.has("pooler") ? false : {}, + ...(mode === undefined ? {} : { mode }), + realtime: native || excluded.has("realtime") ? false : {}, + storage: native || excluded.has("storage") ? false : {}, + imgproxy: native || excluded.has("imgproxy") || excluded.has("storage") ? false : {}, + mailpit: native || excluded.has("mailpit") ? false : {}, + pgmeta: native || excluded.has("pgmeta") ? false : {}, + studio: native || excluded.has("studio") || excluded.has("pgmeta") ? false : {}, + analytics: native || excluded.has("analytics") ? false : {}, + vector: native || excluded.has("vector") || excluded.has("analytics") ? false : {}, + pooler: native || excluded.has("pooler") ? false : {}, ...(excluded.has("auth") ? { auth: false } : {}), ...(excluded.has("postgrest") ? { postgrest: false } : {}), }; diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index d60e7d20fa..46ef8ffd67 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -2,28 +2,38 @@ import { describe, expect, it } from "vitest"; import { toStartStackConfig, withServiceVersions } from "./stack-config.ts"; describe("toStartStackConfig", () => { - it("uses lazy service startup with the requested runtime mode", () => { - expect(toStartStackConfig([], "auto")).toMatchObject({ - mode: "auto", - startupMode: "lazy", + it("leaves mode unset so the stack package can select the usable runtime", () => { + expect(toStartStackConfig([], undefined)).not.toHaveProperty("mode"); + }); + + it("uses the requested runtime mode and catalog service defaults", () => { + expect(toStartStackConfig([], "docker")).toMatchObject({ + mode: "docker", }); expect(toStartStackConfig([], "docker")).toMatchObject({ mode: "docker", - startupMode: "lazy", }); expect(toStartStackConfig([], "native")).toMatchObject({ mode: "native", - startupMode: "lazy", + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, }); }); it("dedupes excluded services when building stack config", () => { - expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({ - mode: "auto", + expect(toStartStackConfig(["auth", "auth"], "docker")).toMatchObject({ + mode: "docker", auth: false, }); - expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({ - mode: "auto", + expect(toStartStackConfig(["auth", "postgrest"], "docker")).toMatchObject({ + mode: "docker", auth: false, postgrest: false, }); @@ -33,7 +43,7 @@ describe("toStartStackConfig", () => { describe("withServiceVersions", () => { it("injects linked service versions without re-enabling excluded services", () => { expect( - withServiceVersions(toStartStackConfig([], "auto"), { + withServiceVersions(toStartStackConfig([], "docker"), { postgres: "17.6.1.090", postgrest: "14.5", auth: "2.187.0", @@ -49,7 +59,7 @@ describe("withServiceVersions", () => { }); expect( - withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), { + withServiceVersions(toStartStackConfig(["auth", "storage"], "docker"), { postgres: "17.6.1.090", auth: "2.187.0", storage: "1.39.2", diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index 4d3a374a95..4da25f32c0 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -72,6 +72,45 @@ describe("stack e2e cleanup manager", () => { expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); }); + it("keeps cleanup best-effort when the associated home cannot be disposed", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: [], + stackDirs: [], + trackedPids: [], + }), + }), + ); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError("home is not removable"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: async () => { + calls.push("cleanup-project"); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(manager.drain()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("/tmp/home")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("home is not removable")); + } finally { + warn.mockRestore(); + } + expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); + }); + it("canonicalizes symlinked project and home paths before matching stack state", async () => { const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-")); const project = join(root, "project"); 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 0bb1d947c2..8210a0dc26 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 @@ -360,7 +360,7 @@ describe("workspace package error tags have external adapters", () => { ]; for (const packageRoot of packageRoots) { - it(packageRoot, async () => { + it(packageRoot, { timeout: 30_000 }, async () => { const tagsByFile = await scanErrorTags(resolve(repoRoot, packageRoot), { exportedOnly: true, }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 2e532a5c1f..575bf42f50 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -983,6 +983,9 @@ const externalActionabilityByTag: Record = { ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } : actionability.unknown, BinaryNotFoundError: () => actionability.invalidConfig, + BinaryManifestError: () => actionability.externalNetwork, + BinaryRuntimeError: () => actionability.externalNetwork, + BinaryHostCompatibilityError: () => actionability.invalidConfig, DownloadError: () => actionability.externalNetwork, ChecksumMismatchError: () => ({ ...actionability.externalNetwork, @@ -1013,6 +1016,10 @@ const externalActionabilityByTag: Record = { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation", }), + AtomicClaimUnsupportedError: () => ({ + ...actionability.invalidInput, + fingerprint_suffix: "managed_identity", + }), StackNotRunningError: () => actionability.startStack, StackReadinessError: () => actionability.startStack, NoRunningStackError: () => actionability.startStack, diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 0ac4e85d51..712dad8ea8 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -24,7 +24,12 @@ import { CliConfig } from "../../src/next/config/cli-config.service.ts"; import { ProjectHome } from "../../src/next/config/project-home.service.ts"; import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; -const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [] }; +const launch = { + mode: "docker" as const, + containerRuntime: "docker" as const, + versions: { postgres: "17.6.1" }, + excludedServices: [], +}; const portDocument: ManagedPortIntentDocument = { activeFields: ["apiPort", "dbPort"], document: {}, diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index b6815fecc8..7f69b7e9d4 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -441,7 +441,11 @@ export function createStackE2eCleanupManager( failures.push(cleanupErrorDetail(project.dir, error)); } finally { if (home !== undefined) { - home.dispose(); + try { + home.dispose(); + } catch (error) { + failures.push(cleanupErrorDetail(home.dir, error)); + } } } } diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 68cb90acb3..880b43f601 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -23,15 +23,24 @@ Launch updates use the existing owner control route (`POST /managed/launch`). An attached caller asks the owner to update launch metadata; a caller with owned control updates the document directly. Stop acquires control first, waits for the persisted `stopped` lifecycle, and handles a stale owner with -deterministic cleanup keyed by stack id. Delete requires owned control and a -stopped document. +deterministic cleanup keyed by stack id. Delete also requires owned control; +stale running or failed documents are reconciled and cleaned before removal, +while a live owner is never deleted underneath. -Read-only discovery never acquires control ownership. It probes `/owner` and -treats an unreachable, incompatible, or colliding listener as non-live; -mutations still bind the endpoint and fail on a conflict. The endpoint maps 14 -bits of the stack id into `127.0.0.1:49152..65535`, so collisions are possible -and deliberately fail closed for start/stop/delete. This is pragmatic -single-user localhost coordination, not a hostile multi-user security +Every managed document records one concrete launch selection. Native launch +state has `mode: "native"`; container launch state has `mode: "docker"` and +the selected Docker or Podman executable. The document never stores an +unresolved or mode-less launch. Runtime configuration uses the same correlated +union, so impossible mode/runtime combinations are not representable after +selection. + +Read-only discovery never acquires control ownership. It scans the stack id's +deterministic endpoint candidates through `/owner` and treats an unreachable, +incompatible, or colliding listener as non-live. Mutations scan the same +sequence for an existing matching owner, then bind the first available +candidate; exhaustion fails closed. Each candidate maps digest bytes from the +stack id into the reserved loopback range `127.0.0.1:10000..32767`. This is +pragmatic single-user localhost coordination, not a hostile multi-user security boundary. We are not adding control tokens until the threat model or a real collision rate justifies more protocol and persistence machinery. @@ -68,4 +77,7 @@ The architecture is smaller and has one source of truth for managed lifecycle state. Refactors update the manager/facade and its real consumers together; there is no fixture adapter or compatibility layer to keep in sync. The private document format may change with the current build, while destructive cleanup -and control ownership remain explicit safeguards. +and control ownership remain explicit safeguards. A supervisor that attaches +and later takes ownership re-reads this source of truth before choosing the +runtime or cleaning stale resources; it does not act on a pre-takeover +snapshot. diff --git a/packages/process-compose/docs/architecture.md b/packages/process-compose/docs/architecture.md index ef0e2d3a80..e265b17ad6 100644 --- a/packages/process-compose/docs/architecture.md +++ b/packages/process-compose/docs/architecture.md @@ -140,6 +140,12 @@ For each requested definition, `Orchestrator` runs this sequence: The service keeps the same state stream across restart generations. Restart backoff is `min(30 seconds, 2^(restartCount - 1))`. +All public lifecycle mutations (`start`, `startService`, `stop`, `stopService`, `restartService`, +and `updateServiceDefinition`) share one semaphore. The semaphore serializes changes to the graph, +desired-state refs, and `FiberMap`; state reads and lifecycle fibers remain concurrent. This keeps +commands composable without allowing a concurrent stop or graph update to observe a half-applied +mutation. + A no-health-check, `restart: "no"` process is treated as one-shot work. A small isolated poll of `ChildProcessHandle.isRunning` compensates for adapters that can report process completion before their `exitCode` Effect becomes observable; it is not part of the general lifecycle loop. @@ -171,6 +177,10 @@ all later failures use `failureThreshold`, including after an unhealthy-to-healt Initial probe failures are therefore observable rather than leaving the service indefinitely in `Running`. +HTTP probes pass the fiber cancellation signal to `fetch` and apply the configured timeout through +Effect interruption. TCP probes use an Effect callback with socket cleanup, so cancellation closes +the socket instead of leaving an in-flight connection behind. + An unhealthy process uses the same pure restart-budget decision as a process exit. When restart is enabled and the budget is exhausted, the supervisor terminates the child and publishes `Failed` with `pid: null`, `exitCode: null`, and a stable health-exhaustion error. With restart policy `no`, @@ -242,6 +252,11 @@ supervisor: - applies graceful-then-forceful termination; - runs serializable orphan cleanup before it exits. +Its coordination core is one scoped Effect program: child exit and owner-loss signals complete +`Deferred`s, owner liveness is checked by a supervised fiber on a `Schedule`, and cleanup command +timeouts use Effect interruption. The source-file and compiled self-dispatch functions are the +only outer process adapters that call `Effect.runPromise`. + This extra process is required for abrupt owner death: an Effect finalizer cannot run after its own process has already disappeared. Ordinary definitions avoid the extra process. @@ -274,8 +289,12 @@ These contracts exist because compiled-child behavior caused two user-visible in ## Logs `LogBuffer` holds a bounded per-service history and a bounded merged history (10,000 entries each), -plus live per-service and merged `PubSub` streams. `historyAll` can filter by service names. -Streams contain new entries only; callers explicitly request history when they need replay. +plus live per-service and merged sliding `PubSub` streams. `historyAll` can filter by service names. +Streams contain new entries only; callers explicitly request history when they need replay. Live +streams intentionally drop the oldest queued entries for a slow subscriber so process stdout and +stderr draining cannot block; the bounded history refs remain the source of truth for recent +output. Per-service stream and history initialization is serialized with a semaphore to make +first-use concurrent appends deterministic. When a process exits unexpectedly or becomes unhealthy, the orchestrator appends recent buffered output to its diagnostics. diff --git a/packages/process-compose/src/HealthProbe.ts b/packages/process-compose/src/HealthProbe.ts index 8664cc6100..179dfb9d8b 100644 --- a/packages/process-compose/src/HealthProbe.ts +++ b/packages/process-compose/src/HealthProbe.ts @@ -1,5 +1,5 @@ import * as Net from "node:net"; -import { Duration, Effect, Ref, Schedule } from "effect"; +import { Duration, Effect, Match, Ref, Schedule } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { defaults, type HealthCheckConfig, type ProbeConfig } from "./ServiceDef.ts"; @@ -7,19 +7,20 @@ const executeProbe = ( probe: ProbeConfig, timeoutSeconds: number, ): Effect.Effect => { - switch (probe._tag) { - case "Http": - return Effect.tryPromise({ - try: () => + return Match.valueTags(probe, { + Http: (probe) => + Effect.tryPromise({ + try: (signal) => fetch(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`, { - signal: AbortSignal.timeout(timeoutSeconds * 1000), + signal, }), - catch: () => false as never, + catch: (cause) => cause, }).pipe( + Effect.timeout(Duration.seconds(timeoutSeconds)), Effect.map((res) => res.ok), Effect.catch(() => Effect.succeed(false)), - ); - case "Exec": { + ), + Exec: (probe) => { const cmd = ChildProcess.make(probe.command, probe.args, { env: probe.env, extendEnv: true, @@ -31,9 +32,9 @@ const executeProbe = ( Effect.map((opt) => opt ?? false), ), ).pipe(Effect.catch(() => Effect.succeed(false))); - } - case "Tcp": - return Effect.callback((resume) => { + }, + Tcp: (probe) => + Effect.callback((resume) => { const socket = Net.createConnection({ host: probe.host, port: probe.port }); socket.once("connect", () => { socket.destroy(); @@ -48,8 +49,8 @@ const executeProbe = ( Effect.timeout(Duration.seconds(timeoutSeconds)), Effect.map((opt) => opt ?? false), Effect.catch(() => Effect.succeed(false)), - ); - } + ), + }); }; export interface HealthProbeCallbacks { diff --git a/packages/process-compose/src/HealthProbe.unit.test.ts b/packages/process-compose/src/HealthProbe.unit.test.ts index a2ac95c4e7..452ec998c6 100644 --- a/packages/process-compose/src/HealthProbe.unit.test.ts +++ b/packages/process-compose/src/HealthProbe.unit.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "@effect/vitest"; import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; -import { Deferred, Duration, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { Deferred, Duration, Effect, Exit, Fiber, Layer, Predicate, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { runHealthProbe } from "./HealthProbe.ts"; import type { HealthCheckConfig, ProbeConfig } from "./ServiceDef.ts"; @@ -79,6 +79,37 @@ const setupProbe = (probe: ProbeConfig, overrides?: Partial) }); describe("HealthProbe", () => { + it.live("aborts an in-flight HTTP probe when its fiber is interrupted", () => { + const originalFetch = globalThis.fetch; + return Effect.gen(function* () { + const started = yield* Deferred.make(); + let aborted = false; + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + }); + Effect.runSync(Deferred.succeed(started, void 0)); + return new Promise(() => undefined); + }) as typeof fetch; + + const { config } = yield* setupProbe({ + _tag: "Http", + scheme: "http", + host: "127.0.0.1", + port: 80, + path: "/health", + }); + const fiber = yield* Effect.forkChild(runHealthProbe(config), { startImmediately: true }); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + + expect(aborted).toBe(true); + }).pipe( + Effect.ensuring(Effect.sync(() => (globalThis.fetch = originalFetch))), + Effect.provide(platformLayer), + ); + }); + it.live("Exec probes require explicit args", () => Effect.sync(() => { // @ts-expect-error Exec probes must declare args explicitly. @@ -129,7 +160,7 @@ describe("HealthProbe", () => { ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.sync(() => { - if (command._tag === "StandardCommand") { + if (Predicate.isTagged(command, "StandardCommand")) { spawned.push({ command: command.command, args: command.args, diff --git a/packages/process-compose/src/LogBuffer.ts b/packages/process-compose/src/LogBuffer.ts index b561d7e6fe..673f00fb05 100644 --- a/packages/process-compose/src/LogBuffer.ts +++ b/packages/process-compose/src/LogBuffer.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, PubSub, Ref, Context, Stream } from "effect"; +import { Context, Effect, Layer, PubSub, Ref, Semaphore, Stream } from "effect"; export interface LogEntry { readonly timestamp: number; @@ -31,13 +31,16 @@ export class LogBuffer extends Context.Service< Effect.gen(function* () { const servicePubSubs = new Map>(); const serviceBuffers = new Map>>(); - const globalPubSub = yield* PubSub.bounded(4096); + // Log delivery must never block child stdout/stderr draining. The bounded history refs + // below remain authoritative; live subscribers receive the newest entries when slow. + const globalPubSub = yield* PubSub.sliding(4096); const globalBuffer = yield* Ref.make>([]); + const serviceInitialization = Semaphore.makeUnsafe(1); const getOrCreate = (service: string) => Effect.gen(function* () { if (!servicePubSubs.has(service)) { - const ps = yield* PubSub.bounded(1024); + const ps = yield* PubSub.sliding(1024); servicePubSubs.set(service, ps); serviceBuffers.set(service, Ref.makeUnsafe>([])); } @@ -45,7 +48,7 @@ export class LogBuffer extends Context.Service< pubsub: servicePubSubs.get(service)!, buffer: serviceBuffers.get(service)!, }; - }); + }).pipe(serviceInitialization.withPermit); return { append: (service, stream, line) => diff --git a/packages/process-compose/src/LogBuffer.unit.test.ts b/packages/process-compose/src/LogBuffer.unit.test.ts index c758785707..4822364862 100644 --- a/packages/process-compose/src/LogBuffer.unit.test.ts +++ b/packages/process-compose/src/LogBuffer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Fiber, Stream } from "effect"; +import { Deferred, Duration, Effect, Fiber, Option, Stream } from "effect"; import { LogBuffer } from "./LogBuffer.ts"; const layer = LogBuffer.layer; @@ -39,10 +39,7 @@ describe("LogBuffer", () => { // Start collecting 1 entry from the subscription in background const collectEffect = log.subscribe("svc").pipe(Stream.take(1), Stream.runCollect); - const fiber = yield* Effect.forkChild(collectEffect); - - // Give the subscriber a moment to be registered - yield* Effect.yieldNow; + const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true }); yield* log.append("svc", "stdout", "hello"); @@ -75,9 +72,7 @@ describe("LogBuffer", () => { // Collect 3 entries from the global subscription const collectEffect = log.subscribeAll().pipe(Stream.take(3), Stream.runCollect); - const fiber = yield* Effect.forkChild(collectEffect); - - yield* Effect.yieldNow; + const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true }); yield* log.append("svcA", "stdout", "from-a"); yield* log.append("svcB", "stderr", "from-b"); @@ -114,6 +109,56 @@ describe("LogBuffer", () => { }).pipe(Effect.provide(layer)), ); + it.live("does not block appends when a live subscriber is slow", () => + Effect.gen(function* () { + const log = yield* LogBuffer; + const firstEntry = yield* Deferred.make(); + const releaseSubscriber = yield* Deferred.make(); + let first = true; + const subscriber = yield* Effect.forkChild( + log.subscribe("svc").pipe( + Stream.tap(() => + first + ? Effect.sync(() => { + first = false; + }).pipe( + Effect.andThen(Deferred.succeed(firstEntry, void 0)), + Effect.andThen(Deferred.await(releaseSubscriber)), + ) + : Effect.void, + ), + Stream.runDrain, + ), + { startImmediately: true }, + ); + yield* log.append("svc", "stdout", "first"); + yield* Deferred.await(firstEntry); + const result = yield* Effect.forEach( + Array.from({ length: 1_100 }, (_, index) => index + 1), + (index) => log.append("svc", "stdout", `line${index}`), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.timeoutOption(Duration.seconds(2))); + + expect(Option.isSome(result)).toBe(true); + yield* Deferred.succeed(releaseSubscriber, void 0); + yield* Fiber.interrupt(subscriber); + }).pipe(Effect.provide(layer)), + ); + + it.live("preserves entries when several writers initialize one service concurrently", () => + Effect.gen(function* () { + const log = yield* LogBuffer; + yield* Effect.forEach( + Array.from({ length: 32 }, (_, index) => index), + (index) => log.append("svc", "stdout", `line${index}`), + { concurrency: "unbounded", discard: true }, + ); + const entries = yield* log.history("svc", 32); + expect(entries).toHaveLength(32); + expect(new Set(entries.map((entry) => entry.line)).size).toBe(32); + }).pipe(Effect.provide(layer)), + ); + it.live("historyAll returns merged entries in timestamp order and respects filters", () => Effect.gen(function* () { const log = yield* LogBuffer; diff --git a/packages/process-compose/src/Orchestrator.integration.test.ts b/packages/process-compose/src/Orchestrator.integration.test.ts index c9d9e72daa..8990f6657e 100644 --- a/packages/process-compose/src/Orchestrator.integration.test.ts +++ b/packages/process-compose/src/Orchestrator.integration.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from "@effect/vitest"; import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; -import { Duration, Effect, Layer } from "effect"; +import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"; import { buildGraph } from "./DependencyGraph.ts"; import { LogBuffer } from "./LogBuffer.ts"; import { Orchestrator } from "./Orchestrator.ts"; +import type { ServiceState } from "./ServiceState.ts"; import type { ProbeConfig, ServiceDef } from "./ServiceDef.ts"; const spawnerLayer = BunChildProcessSpawnerLayer.pipe( @@ -29,22 +30,104 @@ const fileExistsProbe = (path: string) => args: ["-f", path], }) satisfies ProbeConfig; -/** Simple poll: check condition every intervalMs, give up after maxMs */ -const poll = ( - check: Effect.Effect, - intervalMs = 50, - maxMs = 5000, -): Effect.Effect => +type StateReader = { + readonly getAllStates: () => Effect.Effect>; + readonly allStateChanges: () => Stream.Stream; +}; + +const waitForStatuses = ( + orc: StateReader, + predicates: ReadonlyArray<{ + readonly name: string; + readonly predicate: (state: ServiceState) => boolean; + }>, +): Effect.Effect => Effect.gen(function* () { - const start = Date.now(); - while (Date.now() - start < maxMs) { - const ok = yield* check; - if (ok) return; - yield* Effect.sleep(Duration.millis(intervalMs)); - } + const current = yield* orc.getAllStates(); + const matches = (states: ReadonlyArray) => + predicates.every(({ name, predicate }) => { + const state = states.find((candidate) => candidate.name === name); + return state !== undefined && predicate(state); + }); + if (matches(current)) return; + + yield* orc.allStateChanges().pipe( + Stream.scan(new Map(current.map((state) => [state.name, state])), (states, state) => + new Map(states).set(state.name, state), + ), + Stream.filter((states) => matches([...states.values()])), + Stream.take(1), + Stream.runDrain, + ); }); describe("Orchestrator integration", () => { + it.live( + "serializes a concurrent start and stop lifecycle command", + () => { + const defs: ServiceDef[] = [ + { + name: "serialized", + command: "sh", + args: ["-c", "trap '' TERM; sleep 60"], + shutdown: { signal: "SIGTERM", timeoutSeconds: 0.5 }, + }, + ]; + const { layer } = setupReal(defs); + + return Effect.gen(function* () { + const orc = yield* Orchestrator; + const changes = yield* orc.stateChanges("serialized"); + const running = yield* changes.pipe( + Stream.filter((state) => isUp(state.status)), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + yield* orc.start(); + yield* Fiber.join(running); + + const stopping = yield* changes.pipe( + Stream.filter((state) => state.status === "Stopping"), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + const events: Array = []; + const startEntered = yield* Deferred.make(); + const stop = orc.stop().pipe( + Effect.tap(() => + Effect.sync(() => { + events.push("stop"); + }), + ), + ); + const stopFiber = yield* Effect.forkChild(stop, { startImmediately: true }); + yield* Fiber.join(stopping); + + const startFiber = yield* Effect.forkChild( + orc.startService("serialized", { + beforeStart: () => + Effect.sync(() => events.push("start")).pipe( + Effect.andThen(Deferred.succeed(startEntered, void 0)), + ), + }), + { startImmediately: true }, + ); + yield* Fiber.join(stopFiber); + yield* Fiber.join(startFiber); + const startResult = yield* Deferred.await(startEntered).pipe( + Effect.timeoutOption(Duration.seconds(2)), + ); + expect(Option.isSome(startResult)).toBe(true); + + expect(events).toEqual(["stop", "start"]); + yield* orc.stop(); + }).pipe(Effect.provide(layer), Effect.scoped); + }, + { timeout: 15000 }, + ); + it.live( "starts services in dependency order (A before B)", () => { @@ -70,13 +153,10 @@ describe("Orchestrator integration", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const a = yield* orc.getState("service-a"); - const b = yield* orc.getState("service-b"); - return isUp(a.status) && isUp(b.status); - }), - ); + yield* waitForStatuses(orc, [ + { name: "service-a", predicate: (state) => isUp(state.status) }, + { name: "service-b", predicate: (state) => isUp(state.status) }, + ]); const stateA = yield* orc.getState("service-a"); const stateB = yield* orc.getState("service-b"); @@ -119,12 +199,9 @@ describe("Orchestrator integration", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const state = yield* orc.getState("flag-service"); - return state.status === "Healthy"; - }), - ); + yield* waitForStatuses(orc, [ + { name: "flag-service", predicate: (state) => state.status === "Healthy" }, + ]); const state = yield* orc.getState("flag-service"); expect(state.status).toBe("Healthy"); @@ -148,13 +225,10 @@ describe("Orchestrator integration", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const a = yield* orc.getState("long-a"); - const b = yield* orc.getState("long-b"); - return isUp(a.status) && isUp(b.status); - }), - ); + yield* waitForStatuses(orc, [ + { name: "long-a", predicate: (state) => isUp(state.status) }, + { name: "long-b", predicate: (state) => isUp(state.status) }, + ]); const a = yield* orc.getState("long-a"); const b = yield* orc.getState("long-b"); @@ -182,12 +256,11 @@ describe("Orchestrator integration", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const states = yield* orc.getAllStates(); - return states.every((s) => isUp(s.status)); - }), - ); + yield* waitForStatuses(orc, [ + { name: "sleep-a", predicate: (state) => isUp(state.status) }, + { name: "sleep-b", predicate: (state) => isUp(state.status) }, + { name: "sleep-c", predicate: (state) => isUp(state.status) }, + ]); const before = Date.now(); yield* orc.stop(); @@ -219,17 +292,13 @@ describe("Orchestrator integration", () => { return Effect.gen(function* () { const orc = yield* Orchestrator; const logBuffer = yield* LogBuffer; - - yield* orc.start(); - - yield* poll( - Effect.gen(function* () { - const entries = yield* logBuffer.history("echo-svc", 10); - return entries.length >= 3; - }), + const linesReady = yield* Effect.forkChild( + logBuffer.subscribe("echo-svc").pipe(Stream.take(3), Stream.runCollect), + { startImmediately: true }, ); - const entries = yield* logBuffer.history("echo-svc", 10); + yield* orc.start(); + const entries = yield* Fiber.join(linesReady); const lines = entries.map((e) => e.line); expect(lines).toContain("line-one"); expect(lines).toContain("line-two"); @@ -276,13 +345,10 @@ describe("resource cleanup", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const a = yield* orc.getState("svc-a"); - const b = yield* orc.getState("svc-b"); - return isUp(a.status) && isUp(b.status); - }), - ); + yield* waitForStatuses(orc, [ + { name: "svc-a", predicate: (state) => isUp(state.status) }, + { name: "svc-b", predicate: (state) => isUp(state.status) }, + ]); const pidA = (yield* orc.getState("svc-a")).pid!; const pidB = (yield* orc.getState("svc-b")).pid!; @@ -326,13 +392,10 @@ describe("resource cleanup", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const a = yield* orc.getState("target"); - const b = yield* orc.getState("bystander"); - return isUp(a.status) && isUp(b.status); - }), - ); + yield* waitForStatuses(orc, [ + { name: "target", predicate: (state) => isUp(state.status) }, + { name: "bystander", predicate: (state) => isUp(state.status) }, + ]); const pidTarget = (yield* orc.getState("target")).pid!; const pidBystander = (yield* orc.getState("bystander")).pid!; @@ -367,19 +430,13 @@ describe("resource cleanup", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const s = yield* orc.getState("restartable"); - return isUp(s.status); - }), - ); + yield* waitForStatuses(orc, [ + { name: "restartable", predicate: (state) => isUp(state.status) }, + ]); const originalPid = (yield* orc.getState("restartable")).pid!; yield* orc.stopService("restartable"); - // Wait long enough for a restart cycle to prove it doesn't restart - yield* Effect.sleep(Duration.seconds(1)); - expect(isPidAlive(originalPid)).toBe(false); const state = yield* orc.getState("restartable"); expect(state.status).toBe("Stopped"); @@ -417,12 +474,9 @@ describe("resource cleanup", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const s = yield* orc.getState("probed"); - return s.status === "Healthy"; - }), - ); + yield* waitForStatuses(orc, [ + { name: "probed", predicate: (state) => state.status === "Healthy" }, + ]); const pid = (yield* orc.getState("probed")).pid!; yield* orc.stop(); @@ -460,13 +514,10 @@ describe("resource cleanup", () => { const orc = yield* Orchestrator; yield* orc.start(); - yield* poll( - Effect.gen(function* () { - const a = yield* orc.getState("scoped-a"); - const b = yield* orc.getState("scoped-b"); - return isUp(a.status) && isUp(b.status); - }), - ); + yield* waitForStatuses(orc, [ + { name: "scoped-a", predicate: (state) => isUp(state.status) }, + { name: "scoped-b", predicate: (state) => isUp(state.status) }, + ]); capturedPidA = (yield* orc.getState("scoped-a")).pid!; capturedPidB = (yield* orc.getState("scoped-b")).pid!; @@ -474,8 +525,7 @@ describe("resource cleanup", () => { expect(capturedPidB).toBeGreaterThan(0); }).pipe(Effect.provide(layer), Effect.scoped); - // After scope closed, PIDs should be dead - yield* Effect.sleep(Duration.millis(100)); + // Scope closure owns the child finalizers, so they complete before this assertion. expect(isPidAlive(capturedPidA)).toBe(false); expect(isPidAlive(capturedPidB)).toBe(false); }); diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts index ae0560fce2..70028c34fa 100644 --- a/packages/process-compose/src/Orchestrator.ts +++ b/packages/process-compose/src/Orchestrator.ts @@ -7,8 +7,10 @@ import { Fiber, FiberMap, Layer, + Match, Context, Option, + Predicate, Semaphore, Stream, SubscriptionRef, @@ -18,11 +20,7 @@ import { buildGraph, type ResolvedGraph } from "./DependencyGraph.ts"; import { type HealthProbeCallbacks, runHealthProbe } from "./HealthProbe.ts"; import { LogBuffer } from "./LogBuffer.ts"; import { restartClosureFor } from "./RestartClosure.ts"; -import { - decideRestart, - type LifecycleCause, - UNHEALTHY_RESTART_EXHAUSTED_ERROR, -} from "./RestartDecision.ts"; +import { decideRestart, UNHEALTHY_RESTART_EXHAUSTED_ERROR } from "./RestartDecision.ts"; import type { HookTrigger, OrchestratorConfig, @@ -45,14 +43,15 @@ const DIAGNOSTIC_LOG_LINES = 20; const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean => { if (state.exitCode === null) return false; - return ( + return Predicate.isTagged( decideRestart({ cause: { _tag: "ProcessExit", exitCode: state.exitCode }, policy: def.restart ?? defaults.restart, restartCount: state.restartCount, maxRestarts: def.maxRestarts ?? defaults.maxRestarts, desired: state.desired, - })._tag === "Restart" + }), + "Restart", ); }; @@ -62,9 +61,17 @@ const waitForProcessToStop = (handle: { readonly isRunning: Effect.Effect; }): Effect.Effect => Effect.gen(function* () { - while (yield* handle.isRunning.pipe(Effect.catch(() => Effect.succeed(false)))) { - yield* Effect.sleep(Duration.millis(100)); - } + let running = true; + yield* Effect.whileLoop({ + while: () => running, + body: () => + handle.isRunning.pipe( + Effect.catch(() => Effect.succeed(false)), + Effect.tap((next) => Effect.sync(() => (running = next))), + Effect.andThen(Effect.sleep(Duration.millis(100))), + ), + step: () => undefined, + }); }); export class Orchestrator extends Context.Service< @@ -146,7 +153,9 @@ export class Orchestrator extends Context.Service< // FiberMap to track running service fibers — auto-interrupted on scope close const fibers = yield* FiberMap.make(); const forceStops = new Map>(); - const startServiceLock = Semaphore.makeUnsafe(1); + // Serialize every public lifecycle mutation so graph, desired state, and FiberMap + // updates form one command boundary. Read-only state queries remain concurrent. + const lifecycleLock = Semaphore.makeUnsafe(1); // Helper: send a validated FSM event — only does the state transition const sendEvent = ( @@ -516,23 +525,24 @@ export class Orchestrator extends Context.Service< // Handle spawn result const handleResult = (r: SpawnResult) => - Effect.gen(function* () { - if (r._tag === "ProcessExit") { - if (r.exitCode !== 0 && r.exitCode !== 143) { - yield* appendRecentServiceLogs( - def.name, - `[process-exited] Service "${def.name}" exited with code ${r.exitCode}. Recent output:`, - `[process-exited] Service "${def.name}" exited with code ${r.exitCode} (no recent log output).`, - ); - } - yield* sendEvent(def.name, { _tag: "ProcessExited", exitCode: r.exitCode }); - } else if (r._tag === "HookFailed") { - yield* sendEvent(def.name, { _tag: "HookFailed", error: r.error }); - } else { - yield* sendEvent(def.name, { _tag: "ProcessTerminated" }); - } - // Unhealthy is already recorded by the probe. Scope finalization - // terminates its process without inventing a process exit code. + Match.valueTags(r, { + ProcessExit: (result) => + Effect.gen(function* () { + if (result.exitCode !== 0 && result.exitCode !== 143) { + yield* appendRecentServiceLogs( + def.name, + `[process-exited] Service "${def.name}" exited with code ${result.exitCode}. Recent output:`, + `[process-exited] Service "${def.name}" exited with code ${result.exitCode} (no recent log output).`, + ); + } + yield* sendEvent(def.name, { + _tag: "ProcessExited", + exitCode: result.exitCode, + }); + }), + HookFailed: (result) => + sendEvent(def.name, { _tag: "HookFailed", error: result.error }), + Unhealthy: () => Effect.void, }); yield* handleResult(result); @@ -540,24 +550,29 @@ export class Orchestrator extends Context.Service< const svc = services.get(def.name); const desired = svc === undefined ? "inactive" : SubscriptionRef.getUnsafe(svc.state).desired; - if (r._tag === "HookFailed") { - return { _tag: "Terminate", reason: "PolicyDisabled" } as const; - } - const cause: LifecycleCause = - r._tag === "ProcessExit" - ? { _tag: "ProcessExit", exitCode: r.exitCode } - : { _tag: "Unhealthy" }; - return decideRestart({ - cause, - policy: restartPolicy, - restartCount, - maxRestarts, - desired, + return Match.valueTags(r, { + HookFailed: () => ({ _tag: "Terminate", reason: "PolicyDisabled" }) as const, + ProcessExit: (result) => + decideRestart({ + cause: { _tag: "ProcessExit", exitCode: result.exitCode }, + policy: restartPolicy, + restartCount, + maxRestarts, + desired, + }), + Unhealthy: () => + decideRestart({ + cause: { _tag: "Unhealthy" }, + policy: restartPolicy, + restartCount, + maxRestarts, + desired, + }), }); }; let decision = restartDecision(result); - while (decision._tag === "Restart") { + while (Predicate.isTagged(decision, "Restart")) { restartCount = decision.restartCount; yield* sendEvent(def.name, { _tag: "RestartTriggered", restartCount }); @@ -566,7 +581,7 @@ export class Orchestrator extends Context.Service< // be reserved safely for the duration of this restart's backoff. yield* prepareStart(); - if (result._tag === "Unhealthy") { + if (Predicate.isTagged(result, "Unhealthy")) { yield* appendRecentServiceLogs( def.name, `[restart] Service "${def.name}" is restarting after an unhealthy health check. Recent output:`, @@ -588,8 +603,8 @@ export class Orchestrator extends Context.Service< } if ( - result._tag === "Unhealthy" && - decision._tag === "Terminate" && + Predicate.isTagged(result, "Unhealthy") && + Predicate.isTagged(decision, "Terminate") && decision.reason === "BudgetExhausted" ) { yield* sendEvent(def.name, { @@ -740,7 +755,7 @@ export class Orchestrator extends Context.Service< yield* setDesired(def.name, "running"); yield* FiberMap.run(fibers, def.name, runServiceSafe(def, options)); } - }), + }).pipe(lifecycleLock.withPermit), startService: (name: string, options) => Effect.gen(function* () { @@ -784,7 +799,7 @@ export class Orchestrator extends Context.Service< onlyIfMissing: true, }); } - }).pipe(startServiceLock.withPermit), + }).pipe(lifecycleLock.withPermit), stop: () => Effect.gen(function* () { @@ -857,7 +872,7 @@ export class Orchestrator extends Context.Service< yield* Effect.all(forceStops.values(), { concurrency: "unbounded" }); yield* Fiber.await(stopFiber); } - }), + }).pipe(lifecycleLock.withPermit), stopService: (name: string) => Effect.gen(function* () { @@ -871,7 +886,7 @@ export class Orchestrator extends Context.Service< yield* FiberMap.remove(fibers, affectedDef.name); yield* sendEvent(affectedDef.name, { _tag: "ProcessExited", exitCode: 143 }); } - }), + }).pipe(lifecycleLock.withPermit), restartService: (name: string, options) => Effect.gen(function* () { @@ -891,7 +906,7 @@ export class Orchestrator extends Context.Service< for (const affectedDef of affected) { yield* FiberMap.run(fibers, affectedDef.name, runServiceSafe(affectedDef, options)); } - }), + }).pipe(lifecycleLock.withPermit), updateServiceDefinition: (name: string, def: ServiceDef) => Effect.gen(function* () { @@ -905,7 +920,7 @@ export class Orchestrator extends Context.Service< graph.startOrder.map((current) => (current.name === name ? replacement : current)), ); graph = nextGraph; - }), + }).pipe(lifecycleLock.withPermit), getState: (name: string) => Effect.gen(function* () { diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index 520375e86a..276779ea3b 100644 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ b/packages/process-compose/src/Orchestrator.unit.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Duration, Effect, Exit, Fiber, Layer, Option, Sink, Stream } from "effect"; +import { + Deferred, + Duration, + Effect, + Exit, + Fiber, + Layer, + Option, + Predicate, + Sink, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { buildGraph } from "./DependencyGraph.ts"; import { LogBuffer } from "./LogBuffer.ts"; @@ -82,8 +93,7 @@ interface SpawnOpts { function createWaitList() { interface Waiter { readonly ready: () => boolean; - readonly resolve: () => void; - readonly timeout: ReturnType; + readonly signal: Deferred.Deferred; } const waiters = new Set(); @@ -91,33 +101,26 @@ function createWaitList() { const notify = () => { for (const waiter of waiters) { if (waiter.ready()) { - clearTimeout(waiter.timeout); waiters.delete(waiter); - waiter.resolve(); + Effect.runSync(Deferred.succeed(waiter.signal, void 0)); } } }; const waitUntil = (ready: () => boolean, description: string, timeoutMs = 2_000) => - Effect.promise( - () => - new Promise((resolve, reject) => { - if (ready()) { - resolve(); - return; - } - - const waiter: Waiter = { - ready, - resolve, - timeout: setTimeout(() => { - waiters.delete(waiter); - reject(new Error(`Timed out waiting for ${description}`)); - }, timeoutMs), - }; - waiters.add(waiter); - }), - ); + Effect.gen(function* () { + if (ready()) return; + const signal = yield* Deferred.make(); + const waiter: Waiter = { ready, signal }; + waiters.add(waiter); + const result = yield* Deferred.await(signal).pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.ensuring(Effect.sync(() => waiters.delete(waiter))), + ); + if (Option.isNone(result)) { + return yield* Effect.fail(new Error(`Timed out waiting for ${description}`)); + } + }); return { notify, waitUntil }; } @@ -169,8 +172,9 @@ function mockChildProcessSpawner( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; + const standardCommand = Predicate.isTagged(command, "StandardCommand"); + const cmd = standardCommand ? command.command : ""; + const args = standardCommand ? command.args : []; const record: SpawnRecord = { command: cmd, args }; spawned.push(record); opts.onSpawn?.(record); @@ -261,8 +265,9 @@ function mockStuckChildProcessSpawner() { ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; + const standardCommand = Predicate.isTagged(command, "StandardCommand"); + const cmd = standardCommand ? command.command : ""; + const args = standardCommand ? command.args : []; spawned.push({ command: cmd, args }); // exitCode Deferred that is NEVER resolved — simulates stuck process @@ -407,7 +412,7 @@ describe("Orchestrator", () => { return Effect.gen(function* () { const orc = yield* Orchestrator; const exit = yield* orc.getState("nonexistent").pipe(Effect.exit); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer), Effect.scoped); }); @@ -2037,8 +2042,8 @@ describe("Orchestrator", () => { }); const error = yield* orc.waitReady("api").pipe(Effect.flip); - expect(error._tag).toBe("ServiceReadyError"); - if (error._tag === "ServiceReadyError") { + expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true); + if (Predicate.isTagged(error, "ServiceReadyError")) { expect(error.reason).toContain("port reservation failed"); } expect((yield* orc.getState("api")).status).toBe("Failed"); @@ -2133,8 +2138,8 @@ describe("Orchestrator", () => { yield* waitForState(orc, "a", (state) => state.status === "Failed", "Failed"); const error = yield* orc.waitReady("a").pipe(Effect.flip); - expect(error._tag).toBe("ServiceReadyError"); - if (error._tag === "ServiceReadyError") { + expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true); + if (Predicate.isTagged(error, "ServiceReadyError")) { expect(error.reason).toContain("port reservation failed"); } }).pipe(Effect.provide(layer), Effect.scoped); @@ -2222,8 +2227,8 @@ describe("Orchestrator", () => { expect(state.error).toBe("Health check failed and restart budget was exhausted"); const readyError = yield* orc.waitReady("a").pipe(Effect.flip); - expect(readyError._tag).toBe("ServiceReadyError"); - if (readyError._tag === "ServiceReadyError") { + expect(Predicate.isTagged(readyError, "ServiceReadyError")).toBe(true); + if (Predicate.isTagged(readyError, "ServiceReadyError")) { expect(readyError.reason).toBe("Health check failed and restart budget was exhausted"); } }).pipe(Effect.provide(layer), Effect.scoped); diff --git a/packages/process-compose/src/RestartDecision.ts b/packages/process-compose/src/RestartDecision.ts index dc1d2fc5ae..651fb17a11 100644 --- a/packages/process-compose/src/RestartDecision.ts +++ b/packages/process-compose/src/RestartDecision.ts @@ -1,3 +1,4 @@ +import { Predicate } from "effect"; import type { RestartPolicy } from "./ServiceDef.ts"; import type { ServiceDesiredState } from "./ServiceState.ts"; @@ -23,11 +24,14 @@ export function decideRestart(options: { readonly maxRestarts: number; readonly desired: ServiceDesiredState; }): RestartDecision { + const causeIsUnhealthy = Predicate.isTagged(options.cause, "Unhealthy"); + const processExit = Predicate.isTagged(options.cause, "ProcessExit") ? options.cause : undefined; + if (options.desired !== "running") { return { _tag: "Terminate", reason: "NotDesired" }; } - if (options.cause._tag === "Unhealthy" && options.policy === "no") { + if (causeIsUnhealthy && options.policy === "no") { return { _tag: "KeepRunningUnhealthy" }; } @@ -35,7 +39,7 @@ export function decideRestart(options: { options.policy === "always" || options.policy === "unless-stopped" || (options.policy === "on-failure" && - (options.cause._tag === "Unhealthy" || options.cause.exitCode !== 0)); + (causeIsUnhealthy || (processExit !== undefined && processExit.exitCode !== 0))); if (!policyAllowsRestart) { return { _tag: "Terminate", reason: "PolicyDisabled" }; diff --git a/packages/process-compose/src/RestartDecision.unit.test.ts b/packages/process-compose/src/RestartDecision.unit.test.ts index f8d751daa5..8e7aa62fa5 100644 --- a/packages/process-compose/src/RestartDecision.unit.test.ts +++ b/packages/process-compose/src/RestartDecision.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Predicate } from "effect"; import { decideRestart, type LifecycleCause } from "./RestartDecision.ts"; import type { RestartPolicy } from "./ServiceDef.ts"; @@ -20,15 +21,14 @@ describe("decideRestart", () => { ["unless-stopped", exit(1), "Restart"], ["unless-stopped", unhealthy, "Restart"], ] as const)("applies %s to %o", (policy, cause, expected) => { - expect( - decideRestart({ - policy, - cause, - restartCount: 0, - maxRestarts: 1, - desired: "running", - })._tag, - ).toBe(expected); + const decision = decideRestart({ + policy, + cause, + restartCount: 0, + maxRestarts: 1, + desired: "running", + }); + expect(Predicate.isTagged(decision, expected)).toBe(true); }); it.each(["no", "on-failure", "always", "unless-stopped"] as const)( diff --git a/packages/process-compose/src/ServiceTransition.ts b/packages/process-compose/src/ServiceTransition.ts index ea9212a754..4cb61da328 100644 --- a/packages/process-compose/src/ServiceTransition.ts +++ b/packages/process-compose/src/ServiceTransition.ts @@ -1,4 +1,4 @@ -import { Effect, SubscriptionRef } from "effect"; +import { Effect, Match, SubscriptionRef } from "effect"; import { ServiceState, type ServiceStatus } from "./ServiceState.ts"; // --------------------------------------------------------------------------- @@ -31,21 +31,15 @@ export type ServiceEvent = // Transition table — every event must classify its legal source statuses // --------------------------------------------------------------------------- -type TransitionTable = { - readonly [Tag in ServiceEvent["_tag"]]: ReadonlySet; -}; - -const transitions: TransitionTable = { - DependenciesSatisfied: new Set(["Pending"]), - DependencyFailed: new Set(["Pending"]), - SpawnFailed: new Set(["Pending", "Starting", "Restarting"]), - ProcessSpawned: new Set(["Starting"]), - HealthCheckPassed: new Set(["Running", "Healthy", "Unhealthy"]), - HealthCheckFailed: new Set(["Running", "Healthy"]), - ProcessTerminated: new Set(["Unhealthy"]), - UnhealthyRestartExhausted: new Set(["Unhealthy"]), - ProcessExited: new Set(["Running", "Healthy", "Unhealthy", "Stopping", "Failed"]), - StopRequested: new Set([ +const transitionSets = { + dependenciesSatisfied: new Set(["Pending"]), + spawnFailed: new Set(["Pending", "Starting", "Restarting"]), + processSpawned: new Set(["Starting"]), + healthCheckPassed: new Set(["Running", "Healthy", "Unhealthy"]), + healthCheckFailed: new Set(["Running", "Healthy"]), + unhealthy: new Set(["Unhealthy"]), + processExited: new Set(["Running", "Healthy", "Unhealthy", "Stopping", "Failed"]), + stopRequested: new Set([ "Pending", "Starting", "Running", @@ -54,102 +48,127 @@ const transitions: TransitionTable = { "Restarting", "Failed", ]), - RestartTriggered: new Set(["Stopped", "Failed", "Unhealthy"]), - BackoffElapsed: new Set(["Restarting"]), - HookFailed: new Set(["Starting", "Running", "Healthy", "Unhealthy"]), -}; - -// --------------------------------------------------------------------------- -// applyEvent — pure function, returns new ServiceState or null if invalid -// --------------------------------------------------------------------------- - -export const applyEvent = (state: ServiceState, event: ServiceEvent): ServiceState | null => { - if (!transitions[event._tag].has(state.status)) return null; - - switch (event._tag) { - case "DependenciesSatisfied": - return new ServiceState({ ...state, status: "Starting" }); - - case "DependencyFailed": - case "SpawnFailed": - return new ServiceState({ + restartTriggered: new Set(["Stopped", "Failed", "Unhealthy"]), + backoffElapsed: new Set(["Restarting"]), + hookFailed: new Set(["Starting", "Running", "Healthy", "Unhealthy"]), +} as const; + +const transitionStatuses = Match.type().pipe( + Match.tag( + "DependenciesSatisfied", + "DependencyFailed", + () => transitionSets.dependenciesSatisfied, + ), + Match.tag("SpawnFailed", () => transitionSets.spawnFailed), + Match.tag("ProcessSpawned", () => transitionSets.processSpawned), + Match.tag("HealthCheckPassed", () => transitionSets.healthCheckPassed), + Match.tag("HealthCheckFailed", () => transitionSets.healthCheckFailed), + Match.tag("ProcessTerminated", "UnhealthyRestartExhausted", () => transitionSets.unhealthy), + Match.tag("ProcessExited", () => transitionSets.processExited), + Match.tag("StopRequested", () => transitionSets.stopRequested), + Match.tag("RestartTriggered", () => transitionSets.restartTriggered), + Match.tag("BackoffElapsed", () => transitionSets.backoffElapsed), + Match.tag("HookFailed", () => transitionSets.hookFailed), + Match.exhaustive, +); + +const applyTransition = Match.type().pipe( + Match.tag( + "DependenciesSatisfied", + () => (state: ServiceState) => new ServiceState({ ...state, status: "Starting" }), + ), + Match.tag( + "DependencyFailed", + "SpawnFailed", + (event) => (state: ServiceState) => + new ServiceState({ ...state, status: "Failed", pid: null, exitCode: null, error: event.error, - }); - - case "ProcessSpawned": - return new ServiceState({ + }), + ), + Match.tag( + "ProcessSpawned", + (event) => (state: ServiceState) => + new ServiceState({ ...state, status: "Running", pid: event.pid, startedAt: event.startedAt, - }); - - case "HealthCheckPassed": - return new ServiceState({ ...state, status: "Healthy" }); - - case "HealthCheckFailed": - return new ServiceState({ ...state, status: "Unhealthy" }); - - case "UnhealthyRestartExhausted": - return new ServiceState({ + }), + ), + Match.tag( + "HealthCheckPassed", + () => (state: ServiceState) => new ServiceState({ ...state, status: "Healthy" }), + ), + Match.tag( + "HealthCheckFailed", + () => (state: ServiceState) => new ServiceState({ ...state, status: "Unhealthy" }), + ), + Match.tag( + "ProcessTerminated", + () => (state: ServiceState) => new ServiceState({ ...state, pid: null }), + ), + Match.tag( + "UnhealthyRestartExhausted", + (event) => (state: ServiceState) => + new ServiceState({ ...state, status: "Failed", pid: null, exitCode: null, error: event.error, - }); - - case "ProcessTerminated": - return new ServiceState({ ...state, pid: null }); - - case "ProcessExited": { - const status: ServiceStatus = - state.status === "Stopping" ? "Stopped" : event.exitCode === 0 ? "Stopped" : "Failed"; - return new ServiceState({ - ...state, - status, - pid: null, - exitCode: event.exitCode, - }); - } - - case "StopRequested": { - // Pending/Restarting have no running process — go straight to Stopped - const stopStatus = - state.status === "Pending" || state.status === "Restarting" ? "Stopped" : "Stopping"; - return new ServiceState({ ...state, status: stopStatus }); - } - - case "RestartTriggered": - return new ServiceState({ + }), + ), + Match.tag("ProcessExited", (event) => (state: ServiceState) => { + const status: ServiceStatus = + state.status === "Stopping" ? "Stopped" : event.exitCode === 0 ? "Stopped" : "Failed"; + return new ServiceState({ ...state, status, pid: null, exitCode: event.exitCode }); + }), + Match.tag("StopRequested", () => (state: ServiceState) => { + const stopStatus = + state.status === "Pending" || state.status === "Restarting" ? "Stopped" : "Stopping"; + return new ServiceState({ ...state, status: stopStatus }); + }), + Match.tag( + "RestartTriggered", + (event) => (state: ServiceState) => + new ServiceState({ ...state, status: "Restarting", pid: null, restartCount: event.restartCount, - }); - - case "BackoffElapsed": - return new ServiceState({ + }), + ), + Match.tag( + "BackoffElapsed", + () => (state: ServiceState) => + new ServiceState({ ...state, status: "Starting", pid: null, exitCode: null, startedAt: null, error: null, - }); + }), + ), + Match.tag( + "HookFailed", + (event) => (state: ServiceState) => + new ServiceState({ ...state, status: "Failed", pid: null, error: event.error }), + ), + Match.exhaustive, +); - case "HookFailed": - return new ServiceState({ - ...state, - status: "Failed", - pid: null, - error: event.error, - }); - } +// --------------------------------------------------------------------------- +// applyEvent — pure function, returns new ServiceState or null if invalid +// --------------------------------------------------------------------------- + +export const applyEvent = (state: ServiceState, event: ServiceEvent): ServiceState | null => { + if (!transitionStatuses(event).has(state.status)) return null; + return applyTransition(event)(state); }; // --------------------------------------------------------------------------- diff --git a/packages/process-compose/src/ServiceTransition.unit.test.ts b/packages/process-compose/src/ServiceTransition.unit.test.ts index c34b260ce2..12b175f1a4 100644 --- a/packages/process-compose/src/ServiceTransition.unit.test.ts +++ b/packages/process-compose/src/ServiceTransition.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Match } from "effect"; import { applyEvent, type ServiceEvent } from "./ServiceTransition.ts"; import { ServiceState, initial, type ServiceStatus } from "./ServiceState.ts"; @@ -75,12 +76,29 @@ describe("ServiceTransition", () => { BackoffElapsed: ["Restarting"], HookFailed: ["Starting", "Running", "Healthy", "Unhealthy"], }; + const legalStatusesFor = Match.type().pipe( + Match.tagsExhaustive({ + DependenciesSatisfied: () => legalStatuses.DependenciesSatisfied, + DependencyFailed: () => legalStatuses.DependencyFailed, + SpawnFailed: () => legalStatuses.SpawnFailed, + ProcessSpawned: () => legalStatuses.ProcessSpawned, + HealthCheckPassed: () => legalStatuses.HealthCheckPassed, + HealthCheckFailed: () => legalStatuses.HealthCheckFailed, + ProcessTerminated: () => legalStatuses.ProcessTerminated, + UnhealthyRestartExhausted: () => legalStatuses.UnhealthyRestartExhausted, + ProcessExited: () => legalStatuses.ProcessExited, + StopRequested: () => legalStatuses.StopRequested, + RestartTriggered: () => legalStatuses.RestartTriggered, + BackoffElapsed: () => legalStatuses.BackoffElapsed, + HookFailed: () => legalStatuses.HookFailed, + }), + ); for (const event of Object.values(events)) { for (const status of statuses) { const state = make("service", { status, pid: 1234 }); - expect(applyEvent(state, event) !== null, `${status} + ${event._tag}`).toBe( - legalStatuses[event._tag].includes(status), + expect(applyEvent(state, event) !== null, `${status}`).toBe( + legalStatusesFor(event).includes(status), ); } } diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts index 4d150bccc1..12a6151f7b 100644 --- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts +++ b/packages/process-compose/src/SupervisorRuntime.unit.test.ts @@ -151,6 +151,102 @@ describe("supervisor-runtime", () => { }, ); + test( + "runs cleanup exactly once when graceful shutdown races with child exit", + { timeout: 15_000 }, + async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-race-")); + const cleanupMarker = path.join(tempDir, "cleanup-runs"); + const readyFile = path.join(tempDir, "ready"); + const childScriptPath = path.join(tempDir, "child.mjs"); + + writeFileSync( + childScriptPath, + [ + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, + `process.on("SIGTERM", () => setTimeout(() => process.exit(0), 25));`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ); + + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: [childScriptPath], + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 1_000, + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + [ + `const { appendFileSync } = require("node:fs");`, + `appendFileSync(${JSON.stringify(cleanupMarker)}, "cleanup\\n");`, + `setTimeout(() => process.exit(0), 250);`, + ].join("\n"), + ], + }, + ], + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); + + try { + await waitFor(() => existsSync(readyFile)); + supervisor.stdin.end(); + await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); + + expect(readFileSync(cleanupMarker, "utf8")).toBe("cleanup\n"); + } finally { + supervisor.kill("SIGKILL"); + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); + + test( + "exits successfully when graceful shutdown races with cleanup-less child exit", + { timeout: 15_000 }, + async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-exit-race-")); + const readyFile = path.join(tempDir, "ready"); + const childScriptPath = path.join(tempDir, "child.mjs"); + + writeFileSync( + childScriptPath, + [ + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ); + + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: [childScriptPath], + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 1_000, + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); + + try { + await waitFor(() => existsSync(readyFile)); + supervisor.stdin.end(); + await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); + + expect(supervisor.exitCode).toBe(0); + } finally { + supervisor.kill("SIGKILL"); + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); + test.each([ [ "non-string command argument", diff --git a/packages/process-compose/src/errors.unit.test.ts b/packages/process-compose/src/errors.unit.test.ts index 073e5279f0..b593159070 100644 --- a/packages/process-compose/src/errors.unit.test.ts +++ b/packages/process-compose/src/errors.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Predicate } from "effect"; import { CyclicDependencyError, MissingDependencyError, @@ -9,27 +10,27 @@ import { describe("errors", () => { it("CyclicDependencyError has correct tag and data", () => { const err = new CyclicDependencyError({ cycle: "a -> b -> a" }); - expect(err._tag).toBe("CyclicDependencyError"); + expect(Predicate.isTagged(err, "CyclicDependencyError")).toBe(true); expect(err.cycle).toBe("a -> b -> a"); }); it("MissingDependencyError has correct tag and data", () => { const err = new MissingDependencyError({ service: "app", dependency: "db" }); - expect(err._tag).toBe("MissingDependencyError"); + expect(Predicate.isTagged(err, "MissingDependencyError")).toBe(true); expect(err.service).toBe("app"); expect(err.dependency).toBe("db"); }); it("ServiceNotFoundError has correct tag and data", () => { const err = new ServiceNotFoundError({ name: "unknown" }); - expect(err._tag).toBe("ServiceNotFoundError"); + expect(Predicate.isTagged(err, "ServiceNotFoundError")).toBe(true); expect(err.name).toBe("unknown"); }); it("SpawnError has correct tag and data", () => { const cause = new Error("ENOENT"); const err = new SpawnError({ service: "postgres", cause }); - expect(err._tag).toBe("SpawnError"); + expect(Predicate.isTagged(err, "SpawnError")).toBe(true); expect(err.service).toBe("postgres"); expect(err.cause).toBe(cause); }); diff --git a/packages/process-compose/src/supervisor-runtime.ts b/packages/process-compose/src/supervisor-runtime.ts index f3114183f5..302278450b 100644 --- a/packages/process-compose/src/supervisor-runtime.ts +++ b/packages/process-compose/src/supervisor-runtime.ts @@ -1,6 +1,7 @@ import { execFileSync, spawn } from "node:child_process"; import { realpathSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { Deferred, Duration, Effect, Fiber, Match, Option, Predicate, Schedule } from "effect"; import type { ChildProcess } from "effect/unstable/process"; import type { ExternalCleanupAction } from "./ServiceDef.ts"; import { @@ -25,6 +26,10 @@ interface ChildExit { readonly signal: NodeJS.Signals | null; } +type SupervisorOutcome = + | { readonly _tag: "ShutdownRequested"; readonly signal: ChildProcess.Signal } + | { readonly _tag: "ChildExited"; readonly exit: ChildExit }; + const DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS = 5_000; const isMain = (() => { @@ -106,8 +111,7 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined => return undefined; } - const tag = getField(value, "_tag"); - if (tag === "RunCommand") { + if (Predicate.isTagged(value, "RunCommand")) { const executable = getField(value, "executable"); const args = stringArrayFrom(getField(value, "args")); const timeoutMs = getField(value, "timeoutMs"); @@ -121,14 +125,14 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined => return undefined; } return { - _tag: tag, + _tag: "RunCommand", executable, args, timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined, }; } - if (tag === "RemovePath") { + if (Predicate.isTagged(value, "RemovePath")) { const path = getField(value, "path"); const recursive = getField(value, "recursive"); const force = getField(value, "force"); @@ -137,7 +141,7 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined => (recursive === undefined || typeof recursive === "boolean") && (force === undefined || typeof force === "boolean") ? { - _tag: tag, + _tag: "RemovePath", path, recursive: typeof recursive === "boolean" ? recursive : undefined, force: typeof force === "boolean" ? force : undefined, @@ -186,224 +190,218 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC }; }; -export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { - if (encodedConfig == null) { - throw new Error("Missing supervisor config"); - } - - const config = parseSupervisorRuntimeConfig(encodedConfig); - const childEnv = withoutSupervisorRuntimeEnv(); - - const isWindows = process.platform === "win32"; - const child = spawn(config.command, config.args ?? [], { - cwd: process.cwd(), - env: childEnv, - stdio: ["ignore", "pipe", "pipe"], - detached: !isWindows, - }); - - if (child.stdout != null) { - child.stdout.pipe(process.stdout); - } - - if (child.stderr != null) { - child.stderr.pipe(process.stderr); - } - - const childExited = new Promise((resolve) => { - child.once("exit", (code, signal) => resolve({ code, signal })); - }); - - let shuttingDown = false; - let ownerWatcher: ReturnType | undefined; - - const waitForChildExit = async (timeoutMs: number): Promise => { - let timeoutId: ReturnType | undefined; - - try { - return await Promise.race([ - childExited.then(() => true), - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(false), timeoutMs); - }), - ]); - } finally { - if (timeoutId != null) { - clearTimeout(timeoutId); - } - } - }; - - const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => { - if (isWindows) { - try { - execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { - stdio: "ignore", - timeout: 5_000, - }); - } catch {} - - return; - } - +const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => { + if (isWindows) { try { - process.kill(-pid, signal); - return; + execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + timeout: 5_000, + }); } catch {} - try { - process.kill(pid, signal); - } catch {} - }; + return; + } - const killChildTree = (signal: ChildProcess.Signal): void => { - if (child.pid != null) { - killProcessTree(child.pid, signal); - } - }; + try { + process.kill(-pid, signal); + return; + } catch {} - const runCleanupCommand = (action: RunCommandAction): Promise => - new Promise((resolve) => { - const cleanupChild = spawn(action.executable, action.args, { - detached: !isWindows, - env: childEnv, - stdio: "ignore", - }); - let timeoutId: ReturnType | undefined; + try { + process.kill(pid, signal); + } catch {} +}; - const finish = () => { - if (timeoutId != null) { - clearTimeout(timeoutId); - } - resolve(); +const isWindows = process.platform === "win32"; + +const waitForExit = ( + childExit: Deferred.Deferred, + timeoutMs: number, +): Effect.Effect => + Deferred.await(childExit).pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.map(Option.isSome), + ); + +const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const childEnv = withoutSupervisorRuntimeEnv(); + const child = yield* Effect.sync(() => + spawn(config.command, config.args ?? [], { + cwd: process.cwd(), + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + detached: !isWindows, + }), + ); + if (child.stdout != null) child.stdout.pipe(process.stdout); + if (child.stderr != null) child.stderr.pipe(process.stderr); + + const childExit = yield* Deferred.make(); + const shutdownRequest = yield* Deferred.make(); + const onChildExit = (code: number | null, signal: NodeJS.Signals | null) => { + Effect.runSync(Deferred.succeed(childExit, { code, signal })); }; + child.once("exit", onChildExit); - cleanupChild.once("error", finish); - cleanupChild.once("exit", finish); - timeoutId = setTimeout(() => { - if (cleanupChild.pid != null) { - killProcessTree(cleanupChild.pid, "SIGKILL"); - } - finish(); - }, action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS); - }); - - const runCleanup = async (): Promise => { - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const removePathWithRetry = async (action: RemovePathAction): Promise => { - for (let attempt = 0; attempt < 20; attempt++) { + const ownerPid = typeof config.ownerPid === "number" ? config.ownerPid : undefined; + const ownerAlive = () => { + if (ownerPid == null) return true; try { - rmSync(action.path, { - recursive: action.recursive ?? true, - force: action.force ?? true, - }); - return; - } catch {} - - await sleep(250); - } - }; - - const runCommands = async () => { - for (const action of config.cleanup ?? []) { - if (action._tag !== "RunCommand") { - continue; + process.kill(ownerPid, 0); + return true; + } catch { + return false; } + }; + const requestShutdown = (signal: ChildProcess.Signal) => { + Effect.runSync(Deferred.succeed(shutdownRequest, signal)); + }; - try { - await runCleanupCommand(action); - } catch {} - } - }; - - // Commands serialize so their worst-case timeouts add together. Each runs in its own Unix - // process group (or uses taskkill on Windows), allowing a timeout to terminate its whole tree. - await Promise.all([ - runCommands(), - ...(config.cleanup ?? []).map((action) => - action._tag === "RemovePath" ? removePathWithRetry(action) : Promise.resolve(), - ), - ]); - }; - - const shutdown = async (signal: ChildProcess.Signal): Promise => { - if (shuttingDown) { - return; - } - - shuttingDown = true; - if (ownerWatcher != null) { - clearInterval(ownerWatcher); - } - killChildTree(signal); - - const exitedGracefully = await waitForChildExit(config.shutdownTimeoutMs ?? 10_000); - if (!exitedGracefully) { - killChildTree("SIGKILL"); - await waitForChildExit(2_000); - } - - await runCleanup(); - process.exit(0); - }; - - process.stdin.resume(); - process.stdin.on("end", () => { - void shutdown(config.shutdownSignal ?? "SIGTERM"); - }); - process.stdin.on("close", () => { - void shutdown(config.shutdownSignal ?? "SIGTERM"); - }); - process.on("SIGINT", () => { - void shutdown("SIGINT"); - }); - process.on("SIGTERM", () => { - void shutdown("SIGTERM"); - }); - - const ownerPid = typeof config.ownerPid === "number" ? config.ownerPid : undefined; - const ownerAlive = () => { - if (ownerPid == null) { - return true; - } - - try { - process.kill(ownerPid, 0); - return true; - } catch { - return false; - } - }; - - if (!ownerAlive()) { - void shutdown(config.shutdownSignal ?? "SIGTERM"); - } else { - ownerWatcher = setInterval(() => { - if (!ownerAlive()) { - void shutdown(config.shutdownSignal ?? "SIGTERM"); - } - }, 500); - ownerWatcher.unref?.(); - } + process.stdin.resume(); + const onStdinEnd = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); + const onStdinClose = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); + const onSigInt = () => requestShutdown("SIGINT"); + const onSigTerm = () => requestShutdown("SIGTERM"); + process.stdin.on("end", onStdinEnd); + process.stdin.on("close", onStdinClose); + process.on("SIGINT", onSigInt); + process.on("SIGTERM", onSigTerm); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + child.removeListener("exit", onChildExit); + process.stdin.removeListener("end", onStdinEnd); + process.stdin.removeListener("close", onStdinClose); + process.removeListener("SIGINT", onSigInt); + process.removeListener("SIGTERM", onSigTerm); + if (child.pid != null) killProcessTree(child.pid, "SIGKILL"); + }), + ); + + const ownerWatcher = yield* Effect.forkChild( + Effect.repeat( + Effect.gen(function* () { + if (!ownerAlive()) { + yield* Deferred.succeed(shutdownRequest, config.shutdownSignal ?? "SIGTERM"); + } + }), + Schedule.spaced(Duration.millis(500)), + ), + ); + + const killChildTree = (signal: ChildProcess.Signal) => + Effect.sync(() => { + if (child.pid != null) killProcessTree(child.pid, signal); + }); - void childExited.then(async ({ code, signal }) => { - if (shuttingDown) { - return; - } + const runCleanupCommand = (action: RunCommandAction): Effect.Effect => + Effect.callback((resume) => { + const cleanupChild = spawn(action.executable, action.args, { + detached: !isWindows, + env: childEnv, + stdio: "ignore", + }); + const finish = () => resume(Effect.void); + cleanupChild.once("error", finish); + cleanupChild.once("exit", finish); + return Effect.sync(() => { + cleanupChild.removeListener("error", finish); + cleanupChild.removeListener("exit", finish); + if (cleanupChild.pid != null) killProcessTree(cleanupChild.pid, "SIGKILL"); + }); + }).pipe( + Effect.timeoutOption( + Duration.millis(action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS), + ), + Effect.asVoid, + Effect.catch(() => Effect.void), + ); + + const runCleanup = Effect.gen(function* () { + const removePathWithRetry = (action: RemovePathAction) => + Effect.try({ + try: () => { + rmSync(action.path, { + recursive: action.recursive ?? true, + force: action.force ?? true, + }); + }, + catch: (cause) => cause, + }).pipe( + Effect.retry(Schedule.spaced(Duration.millis(250)).pipe(Schedule.upTo({ times: 19 }))), + Effect.catch(() => Effect.void), + ); + yield* Effect.all( + [ + Effect.forEach( + config.cleanup ?? [], + (action) => + Predicate.isTagged(action, "RunCommand") ? runCleanupCommand(action) : Effect.void, + { concurrency: 1, discard: true }, + ), + Effect.forEach( + config.cleanup ?? [], + (action) => + Predicate.isTagged(action, "RemovePath") + ? removePathWithRetry(action) + : Effect.void, + { concurrency: "unbounded", discard: true }, + ), + ], + { discard: true }, + ); + }); - if (!ownerAlive() || (config.cleanup?.length ?? 0) > 0) { - await runCleanup(); - process.exit(0); - return; - } + const shutdown = (signal: ChildProcess.Signal) => + Effect.gen(function* () { + yield* killChildTree(signal); + const exitedGracefully = yield* waitForExit( + childExit, + config.shutdownTimeoutMs ?? 10_000, + ); + if (!exitedGracefully) { + yield* killChildTree("SIGKILL"); + yield* waitForExit(childExit, 2_000); + } + }); - if (signal != null) { - process.exit(1); - return; - } + const outcome = yield* Effect.race( + Deferred.await(shutdownRequest).pipe( + Effect.map((signal): SupervisorOutcome => ({ _tag: "ShutdownRequested", signal })), + ), + Deferred.await(childExit).pipe( + Effect.map((exit): SupervisorOutcome => ({ _tag: "ChildExited", exit })), + ), + ); + + yield* Fiber.interrupt(ownerWatcher); + yield* Match.valueTags(outcome, { + ShutdownRequested: ({ signal }) => + Effect.gen(function* () { + yield* shutdown(signal); + yield* runCleanup; + yield* Effect.sync(() => process.exit(0)); + }), + ChildExited: ({ exit: { code, signal } }) => + Effect.gen(function* () { + if (!ownerAlive() || (config.cleanup?.length ?? 0) > 0) { + yield* runCleanup; + yield* Effect.sync(() => process.exit(0)); + } else if (signal != null) { + yield* Effect.sync(() => process.exit(1)); + } else { + yield* Effect.sync(() => process.exit(code ?? 0)); + } + }), + }); + }), + ); - process.exit(code ?? 0); - }); +export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { + if (encodedConfig == null) throw new Error("Missing supervisor config"); + const config = parseSupervisorRuntimeConfig(encodedConfig); + void Effect.runPromise(runSupervisorRuntimeEffect(config)).catch(() => process.exit(1)); } if (isMain) { diff --git a/packages/process-compose/tests/helpers/mocks.ts b/packages/process-compose/tests/helpers/mocks.ts index 2df118417b..13922bd1bf 100644 --- a/packages/process-compose/tests/helpers/mocks.ts +++ b/packages/process-compose/tests/helpers/mocks.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; interface SpawnRecord { @@ -44,8 +44,8 @@ export function mockChildProcessSpawner( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; + const cmd = Predicate.isTagged(command, "StandardCommand") ? command.command : ""; + const args = Predicate.isTagged(command, "StandardCommand") ? command.args : []; const record: SpawnRecord = { command: cmd, args }; yield* opts.beforeSpawn?.(record) ?? Effect.void; spawned.push(record); @@ -54,7 +54,7 @@ export function mockChildProcessSpawner( const exitDeferred = yield* Deferred.make(); let running = true; - yield* Effect.forkDetach( + yield* Effect.forkScoped( Effect.gen(function* () { // Supervisor processes model long-running services. Direct // commands model probes and one-shot helpers, which should diff --git a/packages/stack/README.md b/packages/stack/README.md index b324a54241..4a6186c5b2 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -15,7 +15,9 @@ console.log((await stack.getInfo()).url); `createStack` resolves configuration, reserves ports, and builds a scoped handle. `stack.start()` starts services; disposing the handle stops them and -releases its lease. +releases its lease. When `mode` is omitted, creation uses Docker mode with a +usable Docker or Podman service and otherwise selects native mode. An explicit +mode never falls back to the other one. ## Managed stack @@ -47,7 +49,7 @@ const runtime = projectDir: projectRoot, name: "default", portIntents, - launch: { mode: "auto", versions: {}, excludedServices: [] }, + launch: { mode: "docker", versions: {}, excludedServices: [] }, }); ``` @@ -56,5 +58,10 @@ const runtime = `stopDaemon` and the discovery helpers delegate to the managed lifecycle facade. No CLI metadata file or PID polling is involved. +After a managed supervisor claims a stack, its persisted Docker, Podman, or +native selection remains pinned even if startup later fails. Retry after +restoring or starting that runtime; delete and recreate the stack to choose a +different execution mode. Deletion removes the stack's managed data. + For the end-to-end lifecycle, identity, ports, service execution, transport, compiled-Bun re-entry, and testing boundary, see [How `@supabase/stack` works](docs/architecture.md). diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index d89005bcc9..0ec9f3d7bc 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -35,8 +35,8 @@ sequenceDiagram CLI->>Parent: daemonLayer(config, port intents, launch) Parent->>Child: fork + start message (resolved stack id) - Child->>Manager: ensure workspace + verify stack id Child->>Control: acquire ownership + bind deterministic endpoint + Child->>Manager: ensure workspace + verify stack id Child->>Manager: resolve document, allocate/reuse ports Child->>Manager: write starting Child->>Runtime: build Stack, ApiProxy, and DaemonServer @@ -74,10 +74,19 @@ transport services to the shared implementation. ## Direct runtime -`createStack` resolves configuration, reserves a private port lease, and -prepares/builds a scoped runtime and handle. It does **not** start service -processes. Asset preparation and process-compose graph construction happen -when the handle is first started or a service is activated. +The internal `createStack` Effect validates allocation-free port intents, acquires +one authoritative lease for every active field, then resolves configuration once +with those selected ports and builds a scoped runtime and handle. Temporary roots +created during resolution are tracked and removed on failure. Node and Bun adapt +that handle to the Promise/`AsyncIterable` facade at the package edge; there is no +Promise resolver that can expose a placeholder port set. The lease owns each +socket until its exact runtime consumer takes over the port, and retains every +remaining reservation until disposal. Automatic API-port handoff may retry with +an OS-selected port only before the first successful start, while explicit ports +remain sticky. +The direct runtime does **not** start service processes. Asset preparation and +process-compose graph construction happen when the handle is first started or +a service is activated. `stack.start()` starts services according to the configured startup mode and waits for the selected readiness policy. The handle also exposes status, logs, @@ -86,6 +95,25 @@ owns service processes and releases the lease when disposed. A direct stack never reads or writes managed documents and never coordinates with a sibling stack. +### Concurrency and cleanup + +`DaemonServer` creates one lazily-started, uninterruptible shutdown fiber in +the layer scope. Every stop or terminal-readiness caller joins that fiber, so +concurrent requests share one transaction and interrupting one caller cannot +cancel the owner. The short response-flush signal is also a scoped fiber. + +`StackPreparation` resolves independent services with a concurrency cap of four. +Its closure includes the resources for every public graph dependency a requested +service can start, so Docker never auto-pulls outside the preparation pipeline. +Each service resolves one canonical GHCR image. Pulls retry only transient +registry and network failures on a one-second exponential `Schedule`, capped at +five retries; non-retryable failures surface immediately with deterministic +details. Auto-managed roots are removed through the Effect `FileSystem` with a +bounded retry schedule. Each removal pass is uninterruptible, while the delay +between attempts remains interruptible; cleanup stays scoped to the exact paths +owned by that stack. Stale binary staging directories use the same small +concurrency cap during reconciliation. + ## Managed lifecycle ### One document and one owner @@ -101,10 +129,9 @@ runtime logs, and `runtime/` for supervisor-owned runtime files. The `ManagedStackManager` is the only component that writes `stack.json`. Control ownership is the liveness and mutation authority. `acquireControl` -returns `Owned` for the process that bound one of the deterministic endpoint -candidates or `Attached` for a live owner found on any candidate. An attached -caller uses the owner's actual endpoint for runtime requests; it never edits -the document directly. +returns `Owned` for the process that bound the deterministic endpoint or +`Attached` for a live owner. An attached caller uses the owner's endpoint for +runtime requests; it never edits the document directly. ### Start and attach @@ -114,16 +141,18 @@ the document directly. 2. The child binds the loopback control endpoint first, re-checks workspace discovery, and refuses to continue if the identity no longer derives the same id. -3. The manager resolves the existing document, cleans stale Docker resources - when required, allocates or reuses ports, and records `starting`. +3. The child supervisor removes stale named container resources when required; + after acquiring ownership it re-reads the existing document, selects or + validates its concrete runtime, then the manager allocates or reuses ports + and records `starting`. 4. The child builds the direct runtime and `DaemonServer`, records `running` with its control endpoint, and sends the endpoint to the parent. 5. The parent returns a `RemoteStack` layer. The CLI then calls `stack.start()` over the control transport when service startup is needed. `connectManagedStack` reads the document, probes the deterministic endpoint -candidates without binding them, and returns a `RemoteStack` against the -owner's actual endpoint only when the owner reports a ready running state. Read-only status and discovery therefore do not claim an +without binding it, and returns a `RemoteStack` only when the owner reports a +ready running state. Read-only status and discovery therefore do not claim an endpoint; mutating operations acquire control ownership. ### Update, stop, and delete @@ -134,12 +163,13 @@ endpoint; mutating operations acquire control ownership. - `stopManagedStack` asks an attached owner to perform a graceful `RemoteStack.stop()`, waits for the document to become `stopped`, and lets the owner close the runtime before releasing control. If the old owner is - gone, the facade acquires control, removes Docker containers named for the + gone, the facade acquires control, removes containers named for the stack id, records `stopped`, and does not inspect PIDs or scan processes. -- `deleteManagedStack` requires owned control and removes only a stopped - document and its managed data root. The explicit destructive path can also - remove an invalid document after ownership is acquired; ordinary status and - start operations report corruption instead of guessing. +- `deleteManagedStack` requires owned control, reconciles any owned running or + failed runtime resources, and then removes the document and its managed data + root. The explicit destructive path can also remove an invalid document + after ownership is acquired; ordinary status and start operations report + corruption instead of guessing. ### Failure and recovery @@ -148,10 +178,16 @@ transitions. A startup error records `failed` and releases the port lease. A graceful stop closes the direct runtime before recording `stopped`. If a supervisor crashes, its document and possible runtime artifacts remain. -The next managed start acquires control, reconciles Docker resources by stack -id, and reuses sticky ports according to their persisted `exact` or `automatic` -intent. No PID file, process scan, second metadata file, or registry surgery is -needed for recovery. +The next managed start acquires control, reconciles named container resources by +stack id, and reuses sticky ports according to their persisted `exact` or +`automatic` intent. No PID file, process scan, second metadata file, or registry +surgery is needed for recovery. + +Every document contains a concrete launch selection. Native documents record +`mode: "native"`; container documents record `mode: "docker"` together with +the selected `docker` or `podman` executable. Inputs may omit a mode to request +selection, but unresolved launch state is never persisted. Resolved direct +runtime configuration uses the same correlated native-or-container union. ## Identity and state @@ -201,18 +237,17 @@ persisted endpoint, or another stack's reservation under the normal exact-port rules. A persisted automatic assignment in the control range is invalid and fails loudly rather than being silently migrated. -The control endpoint is derived from the stack id and served on loopback. The -derivation yields a short deterministic candidate sequence rather than a -single port: an owner binds the first free candidate, skipping candidates -occupied by other stacks or unrelated listeners, and readers scan the same -sequence and match the published `ownershipId`. A hash collision between two -stack ids therefore degrades to the collided stack binding its next candidate -instead of failing. Acquisition fails with a typed conflict only when every -candidate is occupied by a foreign listener; a read-only probe treats an -address with no matching owner as non-live and never claims it. An exact -service port can still equal a candidate of an identity that has never -started, so every stack's full candidate set is reserved against exact-port -requests rather than forbidding every explicit port in the reserved range. +A deterministic sequence of eight control endpoint candidates is derived from +the stack id and served on loopback. Acquisition scans for an existing matching +owner, then binds the first available candidate; read-only probes scan the same +sequence without claiming it. A hash collision or unrelated listener consumes +that candidate, and acquisition fails with a typed conflict only when the +sequence cannot yield an unambiguous owner or free endpoint. The manager +reserves every known candidate against service allocation, and the document +records the endpoint the owner actually bound. An exact service port can still +equal a future candidate of an identity that has never started, so that +low-probability conflict is rejected when ownership is acquired rather than +forbidding every explicit port in the reserved range. This is deliberately a small single-user localhost mechanism. The control protocol has no token authentication; ownership, endpoint identity, and @@ -223,14 +258,17 @@ status, service operations, logs, graceful stop, and launch-update routes; ## Service execution and `ApiProxy` `StackPreparation` resolves each enabled service to a verified native binary or -a Docker image. `mode: "native"` uses the supported native services and rejects -Docker-only services; `mode: "docker"` resolves every service to an image; -`mode: "auto"` prefers native artifacts and falls back to Docker. The -`StackBuilder` turns those resolutions into one process-compose graph, so a -stack can run native and Docker-backed services together. Docker resources are -namespaced with the managed stack id; when native Postgres is combined with -Docker services, the graph supplies the platform-specific host address so the -containers can reach it. +a Docker image. Explicit `mode: "native"` uses the supported native services +and rejects Docker-only services; explicit `mode: "docker"` requires a usable +Docker or Podman runtime and resolves every service to an image. When mode is +omitted, selection prefers a usable Docker or Podman runtime and otherwise uses +native mode only on a host for which native artifacts are published; that +automatic fallback disables Docker-only services before ports or managed launch +state are acquired. The selected runtime is then fixed for the stack. Service +versions are normalized to their catalog form before either binary resolution +or Docker image resolution, and `StackBuilder` turns the results into one +process-compose graph. Docker resources are namespaced with the managed stack +id. `ApiProxy` listens on the configured public `apiPort` and routes Supabase API paths (`/auth`, `/rest`, `/functions`, `/realtime`, `/storage`, `/pg`, diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 9a3f98041c..08e49149cc 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Layer, Option, Context, Schedule, Result } from "effect"; +import { Deferred, Effect, Layer, Option, Context, Predicate, Schedule, Result } from "effect"; import { Headers, HttpBody, @@ -137,9 +137,7 @@ function makeProxyHandler( return (req: HttpServerRequest.HttpServerRequest) => Effect.gen(function* () { const activation = yield* activator.activate(opts.service).pipe( - Effect.tapError((error) => - error._tag === "StackReadinessError" ? signalTerminalFailure : Effect.void, - ), + Effect.tapErrorTag("StackReadinessError", () => signalTerminalFailure), Effect.result, ); if (Result.isFailure(activation)) { @@ -198,7 +196,7 @@ function makeProxyHandler( const request = client.execute(outReq); const outRes = yield* opts.retryColdStart === true ? Effect.retry(request, { - while: (error) => error.reason._tag === "TransportError", + while: (error) => Predicate.isTagged(error.reason, "TransportError"), schedule: COLD_START_RETRY_SCHEDULE, }) : request; diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index 00e77cb06a..2428816ec9 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -1,7 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as http from "node:http"; import { gzipSync } from "node:zlib"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime, Predicate } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; @@ -129,7 +129,7 @@ async function startProxy( const proxy = await proxyRuntime.runPromise(ApiProxy); const addr = proxy.address; let url = ""; - if (addr._tag === "TcpAddress") { + if (Predicate.isTagged(addr, "TcpAddress")) { const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; url = `http://${host}:${addr.port}`; } @@ -176,7 +176,7 @@ describe("ApiProxy", () => { const proxy = await runtime.runPromise(ApiProxy); const addr = proxy.address; - if (addr._tag === "TcpAddress") { + if (Predicate.isTagged(addr, "TcpAddress")) { const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; proxyUrl = `http://${host}:${addr.port}`; } @@ -442,7 +442,7 @@ describe("ApiProxy", () => { const deadProxy = await deadRuntime.runPromise(ApiProxy); const deadAddr2 = deadProxy.address; let deadProxyUrl = ""; - if (deadAddr2._tag === "TcpAddress") { + if (Predicate.isTagged(deadAddr2, "TcpAddress")) { const host = deadAddr2.hostname === "0.0.0.0" ? "127.0.0.1" : deadAddr2.hostname; deadProxyUrl = `http://${host}:${deadAddr2.port}`; } diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts index 5dc58fe38d..3813a640ba 100644 --- a/packages/stack/src/BinaryResolver.integration.test.ts +++ b/packages/stack/src/BinaryResolver.integration.test.ts @@ -1,210 +1,838 @@ +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; +import { zstdCompressSync } from "node:zlib"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import { NodeServices } from "@effect/platform-node"; +import { dirname, join } from "node:path"; +import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Deferred, Effect, Fiber, FileSystem, Layer, Predicate } from "effect"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { afterEach } from "vitest"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { DownloadError } from "./errors.ts"; +import { + BinaryHostCompatibilityError, + BinaryManifestError, + BinaryRuntimeError, + ChecksumMismatchError, + DownloadError, +} from "./errors.ts"; import { detectPlatform } from "./Platform.ts"; import { nativeReleaseForService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; -const tempRoots: string[] = []; +const makeRoot = (): string => mkdtempSync(join(tmpdir(), "stack-slim-services-")); -const makeTempRoot = (): string => { - const root = mkdtempSync(join(tmpdir(), "stack-binary-resolver-")); - tempRoots.push(root); - return root; +const makeFixture = ( + root: string, + manifestOverride: Record = {}, + includePostgrest = true, + paddingBytes = 0, +) => { + const source = join(root, "source"); + const tar = join(root, "postgrest.tar"); + const archive = join(root, "postgrest.tar.zst"); + rmSync(source, { recursive: true, force: true }); + mkdirSync(join(source, "bin"), { recursive: true }); + if (includePostgrest) { + writeFileSync(join(source, "bin", "postgrest"), "#!/bin/sh\necho postgrest\n"); + } + if (paddingBytes > 0) { + writeFileSync(join(source, "bin", "padding"), Buffer.alloc(paddingBytes)); + } + execFileSync("tar", ["-cf", tar, "-C", source, "."]); + writeFileSync(archive, zstdCompressSync(readFileSync(tar))); + return { archive: readFileSync(archive), manifestOverride }; }; -const makeArchive = (root: string): Uint8Array => { - const source = join(root, "source"); - const archive = join(root, "auth.tar.gz"); - execFileSync("mkdir", ["-p", source]); - writeFileSync(join(source, "auth"), "#!/bin/sh\necho auth\n"); - execFileSync("tar", ["czf", archive, "-C", source, "."]); - return readFileSync(archive); +const writeTarOctal = (header: Buffer, offset: number, length: number, value: number): void => { + const encoded = `${value.toString(8).padStart(length - 1, "0")}\0`; + header.write(encoded, offset, length, "ascii"); +}; + +const makeTarArchive = (member: string, contents: string): Buffer => { + const payload = Buffer.from(contents); + const header = Buffer.alloc(512); + header.write(member, 0, 100, "utf8"); + writeTarOctal(header, 100, 8, 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, payload.length); + writeTarOctal(header, 136, 12, 0); + header[156] = "0".charCodeAt(0); + header.write("ustar\0", 257, 6, "ascii"); + header.write("00", 263, 2, "ascii"); + header.fill(0x20, 148, 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); + const padding = Buffer.alloc((512 - (payload.length % 512)) % 512); + return Buffer.concat([header, payload, padding, Buffer.alloc(1024)]); +}; + +const makeTraversalFixture = (root: string) => { + const archive = join(root, "postgrest-traversal.tar.zst"); + writeFileSync(join(root, "outside.txt"), "must not extract\n"); + writeFileSync(archive, zstdCompressSync(makeTarArchive("../outside.txt", "must not extract\n"))); + return { archive: readFileSync(archive), manifestOverride: {} }; +}; + +const makeEscapingSymlinkFixture = (root: string) => { + const source = join(root, "symlink-source"); + const tar = join(root, "postgrest-symlink.tar"); + const archive = join(root, "postgrest-symlink.tar.zst"); + rmSync(source, { recursive: true, force: true }); + mkdirSync(join(source, "bin"), { recursive: true }); + symlinkSync("/bin/sh", join(source, "bin/postgrest")); + execFileSync("tar", ["-cf", tar, "-C", source, "."]); + writeFileSync(archive, zstdCompressSync(readFileSync(tar))); + return { archive: readFileSync(archive), manifestOverride: {} }; }; -const makeResolverLayer = (cacheRoot: string, archive: Uint8Array, onRequest: () => void) => { +const makeResolverLayer = ( + cacheRoot: string, + fixture: ReturnType, + options: { + readonly checksum?: string; + readonly checksumText?: string; + readonly spawnedCommands?: Array<{ command: string; args: ReadonlyArray }>; + readonly onChecksumRead?: () => void; + readonly exitCodeForCommand?: (command: string) => number | undefined; + readonly transformFileSystem?: (fileSystem: FileSystem.FileSystem) => FileSystem.FileSystem; + } = {}, +) => { const client = HttpClient.make((request) => Effect.sync(() => { - onRequest(); - return HttpClientResponse.fromWeb(request, new Response(archive, { status: 200 })); + if (request.url.endsWith(".manifest.json")) { + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + target: process.platform === "darwin" ? "darwin-arm64" : "linux-amd64", + entrypoint: [], + cmd: ["/bin/postgrest"], + runtime_requires: null, + libc: process.platform === "linux" ? "glibc" : null, + os_floor: + process.platform === "linux" + ? { kind: "glibc", floor: null, scanned: 1 } + : { kind: "macos", floor: null, scanned: 1 }, + ...fixture.manifestOverride, + }), + { status: 200 }, + ), + ); + } + if (request.url.endsWith("SHA256SUMS")) { + const hash = options.checksum ?? createHash("sha256").update(fixture.archive).digest("hex"); + const checksumText = + options.checksumText ?? + `${hash} postgrest-${DEFAULT_VERSIONS.postgrest}-${process.platform === "darwin" ? "darwin-arm64" : "linux-amd64"}.tar.zst\n`; + const response = HttpClientResponse.fromWeb( + request, + new Response(checksumText, { status: 200 }), + ); + if (options.onChecksumRead !== undefined) { + Object.defineProperty(response, "text", { + value: Effect.sync(() => { + options.onChecksumRead?.(); + return checksumText; + }), + }); + } + return response; + } + return HttpClientResponse.fromWeb(request, new Response(fixture.archive, { status: 200 })); }), ); + const spawnerLayer = + options.spawnedCommands === undefined && options.exitCodeForCommand === undefined + ? NodeServices.layer + : Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const record = (command: ChildProcess.Command) => { + if (Predicate.isTagged(command, "StandardCommand")) { + options.spawnedCommands?.push({ + command: command.command, + args: command.args, + }); + } + }; + const spawner = ChildProcessSpawner.make((command) => { + record(command); + return delegate.spawn(command); + }); + return ChildProcessSpawner.ChildProcessSpawner.of({ + ...spawner, + exitCode: (command) => { + if (!Predicate.isTagged(command, "StandardCommand")) { + return spawner.exitCode(command); + } + const exitCode = options.exitCodeForCommand?.(command.command); + if (exitCode === undefined) return spawner.exitCode(command); + record(command); + return Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)); + }, + }); + }), + ).pipe(Layer.provide(NodeServices.layer)); + const fileSystemLayer = + options.transformFileSystem === undefined + ? NodeFileSystem.layer + : Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, options.transformFileSystem), + ).pipe(Layer.provide(NodeFileSystem.layer)); return BinaryResolver.make(cacheRoot).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), - Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, client), + spawnerLayer, + fileSystemLayer, + NodePath.layer, + ), + ), ); }; -const makeUnavailableResolverLayer = (cacheRoot: string) => { - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), - ), +describe("BinaryResolver slim-services installer", () => { + it.live("keeps the Effect runtime responsive while decompressing an archive", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + let eventLoopAdvanced = false; + let advancedBeforeArchiveWrite = false; + const resolverLayer = makeResolverLayer( + root, + makeFixture(root, {}, true, 32 * 1024 * 1024), + { + onChecksumRead: () => { + setImmediate(() => { + eventLoopAdvanced = true; + }); + }, + transformFileSystem: (fileSystem) => + FileSystem.FileSystem.of({ + ...fileSystem, + writeFile: (requestedPath, data, options) => { + if (requestedPath.endsWith("_download.tar")) { + advancedBeforeArchiveWrite = eventLoopAdvanced; + } + return fileSystem.writeFile(requestedPath, data, options); + }, + }), + }, + ); + + yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(advancedBeforeArchiveWrite).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), ); - return BinaryResolver.make(cacheRoot).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), - Layer.provide(NodeServices.layer), + + it.live("rejects failed binary post-processing before publishing the cache", () => + Effect.gen(function* () { + const commands = [ + ...(process.platform === "win32" ? [] : ["chmod"]), + ...(process.platform === "darwin" ? ["find"] : []), + ]; + for (const failedCommand of commands) { + const root = makeRoot(); + try { + const resolverLayer = makeResolverLayer(root, makeFixture(root), { + exitCodeForCommand: (command) => (command === failedCommand ? 73 : undefined), + }); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + + expect(failure).toBeInstanceOf(BinaryRuntimeError); + if (failure instanceof BinaryRuntimeError) { + expect(failure.detail).toContain("73"); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + }), ); -}; -const authCachePath = (cacheRoot: string) => - Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, platform); - if (release === undefined) { - return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`); - } - return BinaryResolver.cachePath(join(cacheRoot, "bin"), { - service: "auth", - provider: release.provider, - version: DEFAULT_VERSIONS.auth, - assetName: release.assetName, - }); - }); - -const legacyAuthCachePath = (cacheRoot: string) => - Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, platform); - if (release === undefined) { - return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`); - } - return join(cacheRoot, "bin", "auth", DEFAULT_VERSIONS.auth, release.assetName); - }); - -afterEach(() => { - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } -}); + it.live("installs a tar.zst archive into an empty cache and reuses it", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release === undefined) return; + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const first = yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + const second = yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + return { first, second }; + }).pipe(Effect.provide(resolverLayer)); + expect(result.first.downloaded).toBe(true); + expect(result.second.downloaded).toBe(false); + expect(readFileSync(join(result.first.path, "bin/postgrest"), "utf8")).toContain( + "postgrest", + ); + expect(existsSync(join(result.first.path, ".complete"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("rejects a complete cache prepared for an incompatible host", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const resolverLayer = makeResolverLayer(root, makeFixture(root)); + const installed = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + const markerPath = join(installed, ".complete"); + const marker = JSON.parse(readFileSync(markerPath, "utf8")); + marker.hostCompatibility = + process.platform === "darwin" + ? { + runtimeRequires: null, + libc: null, + osFloor: { kind: "macos", floor: "999.0" }, + } + : { + runtimeRequires: "glibc", + libc: "glibc", + osFloor: { kind: "glibc", floor: "999.0" }, + }; + writeFileSync(markerPath, JSON.stringify(marker)); + + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + + expect(failure).toBeInstanceOf(BinaryHostCompatibilityError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("fails closed when the manifest host floor is malformed", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root, { + os_floor: + process.platform === "darwin" + ? { kind: "macos", floor: "not-a-version", scanned: 1 } + : { kind: "glibc", floor: "not-a-version", scanned: 1 }, + }); + const resolverLayer = makeResolverLayer(root, fixture); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + + expect(failure).toBeInstanceOf(BinaryHostCompatibilityError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("installs slim archives without requiring an external zstd executable", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const spawnedCommands: Array<{ command: string; args: ReadonlyArray }> = []; + const resolverLayer = makeResolverLayer(root, makeFixture(root), { spawnedCommands }); + yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(spawnedCommands.some(({ args }) => args.includes("--zstd"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("accepts the Mailpit-style glibc runtime requirement", () => + Effect.gen(function* () { + if (process.platform !== "linux") return; + const root = makeRoot(); + try { + const fixture = makeFixture(root, { runtime_requires: "glibc" }); + const resolverLayer = makeResolverLayer(root, fixture); + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(existsSync(join(result, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("reclaims interrupted staging while preserving complete cache entries", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release === undefined) return; + const cacheDir = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + const stale = join(dirname(cacheDir), `.${release.assetName}.partial-interrupted`); + mkdirSync(stale, { recursive: true }); + const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); + utimesSync(stale, old, old); + + const resolved = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(existsSync(stale)).toBe(false); + expect(existsSync(join(resolved, ".complete"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("limits stale staging reconciliation to four filesystem operations", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release === undefined) return; + const cacheDir = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + const stalePrefix = join(dirname(cacheDir), `.${release.assetName}.partial-cap-`); + const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); + for (let index = 0; index < 5; index += 1) { + const stalePath = `${stalePrefix}${index}`; + mkdirSync(stalePath, { recursive: true }); + utimesSync(stalePath, old, old); + } + + const saturated = yield* Deferred.make(); + const releaseStats = yield* Deferred.make(); + let active = 0; + let maxActive = 0; + const resolverLayer = makeResolverLayer(root, fixture, { + transformFileSystem: (fileSystem) => + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (requestedPath) => { + if (!requestedPath.startsWith(stalePrefix)) return fileSystem.stat(requestedPath); + return Effect.acquireUseRelease( + Effect.sync(() => { + active += 1; + maxActive = Math.max(maxActive, active); + return active; + }).pipe( + Effect.tap((current) => + current === 4 ? Deferred.succeed(saturated, undefined) : Effect.void, + ), + ), + () => + Deferred.await(releaseStats).pipe( + Effect.andThen(fileSystem.stat(requestedPath)), + ), + () => + Effect.sync(() => { + active -= 1; + }), + ); + }, + }), + }); + const resolving = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(saturated); + yield* Effect.yieldNow; + expect(maxActive).toBe(4); + + yield* Deferred.succeed(releaseStats, undefined); + yield* Fiber.join(resolving); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("replaces caches with invalid identity markers or missing required paths", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(result.downloaded).toBe(true); + + const markerPath = join(result.path, ".complete"); + writeFileSync(markerPath, JSON.stringify({ service: "postgrest" })); + const replacedMarker = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(replacedMarker.downloaded).toBe(true); + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toMatchObject({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + }); + + rmSync(join(replacedMarker.path, "bin/postgrest")); + const restoredPath = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(restoredPath.downloaded).toBe(true); + expect(existsSync(join(restoredPath.path, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("preserves a cache published while another resolver repairs stale state", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const installed = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + const markerPath = join(installed.path, ".complete"); + writeFileSync(markerPath, JSON.stringify({ service: "postgrest" })); + + const staleMarkerRead = yield* Deferred.make(); + const releaseStaleReader = yield* Deferred.make(); + let markerReads = 0; + const staleReaderLayer = makeResolverLayer(root, fixture, { + transformFileSystem: (fileSystem) => + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (requestedPath, options) => + Effect.gen(function* () { + const contents = yield* fileSystem.readFileString(requestedPath, options); + if (requestedPath === markerPath) { + markerReads += 1; + if (markerReads === 2) { + yield* Deferred.succeed(staleMarkerRead, undefined); + yield* Deferred.await(releaseStaleReader); + } + } + return contents; + }), + }), + }); + const staleReader = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(staleReaderLayer), Effect.forkChild); + yield* Deferred.await(staleMarkerRead); + + const publisher = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(makeResolverLayer(root, fixture))); + yield* Deferred.succeed(releaseStaleReader, undefined); + const repaired = yield* Fiber.join(staleReader); + + expect(publisher.downloaded).toBe(true); + expect(repaired.downloaded).toBe(false); + expect(repaired.path).toBe(installed.path); + expect(readFileSync(join(installed.path, "bin/postgrest"), "utf8")).toContain("postgrest"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("rejects archive members that escape the private staging directory", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeTraversalFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + expect(failure).toBeInstanceOf(DownloadError); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release !== undefined) { + const cache = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + expect(existsSync(join(dirname(cache), "outside.txt"))).toBe(false); + expect(existsSync(join(cache, ".complete"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("rejects archive symlinks that resolve outside private staging", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const resolverLayer = makeResolverLayer(root, makeEscapingSymlinkFixture(root)); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + expect(failure).toBeInstanceOf(BinaryRuntimeError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("rejects checksum and manifest/runtime validation failures", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture, { checksum: "0".repeat(64) }); + const checksum = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + expect(checksum).toBeInstanceOf(ChecksumMismatchError); + + const manifestLayer = makeResolverLayer( + root, + makeFixture(root, { + target: process.platform === "darwin" ? "linux-amd64" : "darwin-arm64", + }), + ); + const manifest = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(manifestLayer), Effect.flip); + expect(manifest).toBeInstanceOf(BinaryManifestError); + expect(manifest).not.toBeInstanceOf(BinaryRuntimeError); + + const unsafeCommandLayer = makeResolverLayer( + root, + makeFixture(root, { cmd: ["../bin/postgrest"] }), + ); + const unsafeCommand = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(unsafeCommandLayer), Effect.flip); + expect(unsafeCommand).toBeInstanceOf(BinaryManifestError); -describe("BinaryResolver cache publication", () => { - it.live("publishes one complete cache entry for concurrent resolvers", () => { - const root = makeTempRoot(); - const archive = makeArchive(root); - let requestCount = 0; - const layer = makeResolverLayer(root, archive, () => { - requestCount += 1; - }); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const results = yield* Effect.all( - [ - resolver.resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }), - resolver.resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }), - ], - { concurrency: "unbounded" }, - ); - - expect(requestCount).toBe(2); - expect(results.filter((result) => result.downloaded)).toHaveLength(1); - expect(results[0]?.path).toBe(results[1]?.path); - expect(readFileSync(join(results[0]!.path, "auth"), "utf8")).toContain("echo auth"); - expect(readFileSync(join(results[0]!.path, ".complete"), "utf8")).toContain( - "github.com/supabase/auth", - ); - }).pipe(Effect.provide(layer)); - }); - - it.live("reuses a complete cache from the legacy layout without downloading", () => { - const root = makeTempRoot(); - const layer = makeUnavailableResolverLayer(root); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const legacyCacheDir = yield* legacyAuthCachePath(root); - mkdirSync(legacyCacheDir, { recursive: true }); - writeFileSync(join(legacyCacheDir, "auth"), "legacy auth binary"); - - const result = yield* resolver.resolveWithMetadata({ - service: "auth", - version: DEFAULT_VERSIONS.auth, - }); - - expect(result).toEqual({ path: legacyCacheDir, downloaded: false }); - }).pipe(Effect.provide(layer)); - }); - - it.live("preserves a markerless provider cache when replacement download fails", () => { - const root = makeTempRoot(); - const layer = makeUnavailableResolverLayer(root); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const cacheDir = yield* authCachePath(root); - const legacyBinary = join(cacheDir, "auth"); - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(legacyBinary, "legacy auth binary"); - - const error = yield* resolver - .resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(DownloadError); - expect(readFileSync(legacyBinary, "utf8")).toBe("legacy auth binary"); - }).pipe(Effect.provide(layer)); - }); - - it.live("replaces an incomplete provider cache after staging succeeds", () => { - const root = makeTempRoot(); - const archive = makeArchive(root); - const layer = makeResolverLayer(root, archive, () => {}); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const cacheDir = yield* authCachePath(root); - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(join(cacheDir, ".complete"), "orphaned marker"); - - const result = yield* resolver.resolveWithMetadata({ - service: "auth", - version: DEFAULT_VERSIONS.auth, - }); - - expect(result).toEqual({ path: cacheDir, downloaded: true }); - expect(readFileSync(join(cacheDir, "auth"), "utf8")).toContain("echo auth"); - expect(readFileSync(join(cacheDir, ".complete"), "utf8")).toContain( - "github.com/supabase/auth", - ); - }).pipe(Effect.provide(layer)); - }); - - it.live("reaps stale staging directories even when the artifact is cached", () => { - const root = makeTempRoot(); - const archive = makeArchive(root); - const layer = makeResolverLayer(root, archive, () => {}); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const spec = { service: "auth", version: DEFAULT_VERSIONS.auth } as const; - const first = yield* resolver.resolveWithMetadata(spec); - const staleStaging = join(dirname(first.path), `.${basename(first.path)}.partial-abandoned`); - mkdirSync(staleStaging); - writeFileSync(join(staleStaging, "partial"), "partial artifact"); - const staleTime = new Date(Date.now() - 25 * 60 * 60 * 1_000); - utimesSync(staleStaging, staleTime, staleTime); - - const second = yield* resolver.resolveWithMetadata(spec); - - expect(second.downloaded).toBe(false); - expect(existsSync(staleStaging)).toBe(false); - }).pipe(Effect.provide(layer)); - }); + const runtimeLayer = makeResolverLayer(root, makeFixture(root, {}, false)); + const runtime = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(runtimeLayer), Effect.flip); + expect(runtime).toBeInstanceOf(BinaryRuntimeError); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release !== undefined) { + const cache = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + expect(existsSync(join(cache, ".complete"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("fails closed when SHA256SUMS has no entry for the requested archive", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const unrelated = createHash("sha256").update(fixture.archive).digest("hex"); + const resolverLayer = makeResolverLayer(root, fixture, { + checksumText: `${unrelated} unrelated.sbom.spdx.json\n`, + }); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + + expect(failure).toBeInstanceOf(BinaryManifestError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("accepts uppercase SHA256SUMS digests", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const hash = createHash("sha256").update(fixture.archive).digest("hex").toUpperCase(); + const resolverLayer = makeResolverLayer(root, fixture, { checksum: hash }); + const resolved = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(existsSync(join(resolved, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); }); diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts index f6ee4070d4..016ae1d344 100644 --- a/packages/stack/src/BinaryResolver.ts +++ b/packages/stack/src/BinaryResolver.ts @@ -1,14 +1,30 @@ import { createHash } from "node:crypto"; -import { Context, Effect, FileSystem, Layer, Option, Path, Result } from "effect"; +import { zstdDecompress } from "node:zlib"; +import { + Context, + Duration, + Effect, + FileSystem, + Layer, + Option, + Path, + Predicate, + PlatformError, + Result, + Schedule, +} from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { BinaryNotFoundError, ChecksumMismatchError, DownloadError } from "./errors.ts"; -import { detectPlatform } from "./Platform.ts"; import { - nativeReleaseForService, - type ArchiveFormat, - type NativeReleaseArtifact, -} from "./ServiceCatalog.ts"; + BinaryHostCompatibilityError, + BinaryManifestError, + BinaryNotFoundError, + BinaryRuntimeError, + ChecksumMismatchError, + DownloadError, +} from "./errors.ts"; +import { detectPlatform, type NativeTarget } from "./Platform.ts"; +import { nativeReleaseForService, type NativeReleaseArtifact } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; export interface BinarySpec { @@ -22,89 +38,80 @@ interface ResolveBinaryResult { readonly downloaded: boolean; } +interface SlimServiceManifest { + readonly service: string; + readonly version: string; + readonly target: string; + readonly entrypoint: ReadonlyArray; + readonly cmd: ReadonlyArray; + readonly runtime_requires?: null | "glibc"; + readonly libc: null | "glibc"; + readonly os_floor: null | { + readonly kind: string; + readonly floor: string | null; + readonly offender?: string | null; + readonly scanned: number; + readonly bundled_glibc?: boolean; + }; +} + export interface ResolveBinaryOptions { readonly onDownloadStart?: Effect.Effect; } interface AssetInfo { readonly service: ServiceName; - readonly provider: string; + readonly releaseSet: "slim-services"; readonly version: string; - readonly assetName: string; + readonly runtime: "native"; + readonly target: NativeTarget; } -const cachePath = (baseDir: string, info: AssetInfo): string => - `${baseDir}/${info.service}/${info.provider.replaceAll("/", "_")}/${info.version}/${info.assetName}`; - -const LEGACY_NATIVE_PROVIDERS: Partial> = { - postgres: "github.com/supabase/postgres", - postgrest: "github.com/PostgREST/postgrest", - auth: "github.com/supabase/auth", - "edge-runtime": "github.com/supabase/edge-runtime", -}; +interface HostCompatibilityRequirement { + readonly runtimeRequires: null | "glibc"; + readonly libc: null | "glibc"; + readonly osFloor: null | { + readonly kind: "glibc" | "macos"; + readonly floor: string | null; + }; +} -const legacyCachePath = (baseDir: string, info: AssetInfo): string | undefined => - LEGACY_NATIVE_PROVIDERS[info.service] === info.provider - ? `${baseDir}/${info.service}/${info.version}/${info.assetName}` - : undefined; - -const legacyExecutablePath = ( - directory: string, - service: ServiceName, - platformOs: string, -): string | undefined => { - const executableSuffix = platformOs === "win32" ? ".exe" : ""; - switch (service) { - case "postgres": - return `${directory}/bin/postgres${executableSuffix}`; - case "postgrest": - return `${directory}/postgrest${executableSuffix}`; - case "auth": - return `${directory}/auth${executableSuffix}`; - case "edge-runtime": - return `${directory}/bin/edge-runtime${executableSuffix}`; - default: - return undefined; - } -}; +interface CacheCompleteMarker { + readonly provider: string; + readonly service: string; + readonly version: string; + readonly asset: string; + readonly url: string; + readonly target: NativeTarget; + readonly releaseSet: "slim-services"; + readonly runtime: "native"; + readonly hostCompatibility: HostCompatibilityRequirement; +} -const legacyCacheRequiredPaths = ( - directory: string, - service: ServiceName, - platformOs: string, -): ReadonlyArray => { - const executable = legacyExecutablePath(directory, service, platformOs); - if (executable === undefined) return []; - return service === "postgres" - ? [ - executable, - `${directory}/bin/pg_isready${platformOs === "win32" ? ".exe" : ""}`, - `${directory}/bin/psql${platformOs === "win32" ? ".exe" : ""}`, - `${directory}/share/supabase-cli/bin/supabase-postgres-init.sh`, - `${directory}/lib`, - ] - : [executable]; -}; +const cachePath = (baseDir: string, info: AssetInfo): string => + `${baseDir}/${info.releaseSet}/${info.service}/${info.version}/${info.runtime}/${info.target}`; const CACHE_COMPLETE_MARKER = ".complete"; -const STALE_STAGING_AGE_MS = 24 * 60 * 60 * 1_000; - -const extractCommand = ( - archive: ArchiveFormat, - archivePath: string, - destDir: string, - os: string, - stripComponents: boolean, -): string[] => { - if (archive === "zip") { - return os === "win32" - ? ["tar", "xf", archivePath, "-C", destDir] - : ["unzip", "-o", archivePath, "-d", destDir]; +const STALE_PREPARATION_ENTRY_AGE_MS = 24 * 60 * 60 * 1_000; + +const hasTraversalSegment = (value: string): boolean => + value.split(/[\\/]/).some((segment) => segment === ".."); + +const isUnsafeArchiveMember = (member: string): boolean => { + const normalized = member.trim(); + if (normalized.length === 0) return false; + if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized)) return true; + let depth = 0; + for (const segment of normalized.split(/[\\/]/)) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (depth === 0) return true; + depth -= 1; + } else { + depth += 1; + } } - const flag = archive === "tar.gz" ? "xzf" : "xf"; - const args = ["tar", flag, archivePath, "-C", destDir]; - if (stripComponents) args.push("--strip-components=1"); - return args; + return false; }; const verifyChecksum = ( @@ -115,7 +122,7 @@ const verifyChecksum = ( Effect.sync(() => { const actual = createHash("sha256").update(new Uint8Array(data)).digest("hex"); // The .sha256 file typically contains "hex filename" or just "hex" - const expectedHex = expected.trim().split(/\s+/)[0] ?? ""; + const expectedHex = (expected.trim().split(/\s+/)[0] ?? "").toLowerCase(); return { actual, expectedHex }; }).pipe( Effect.flatMap(({ actual, expectedHex }) => { @@ -126,25 +133,328 @@ const verifyChecksum = ( }), ); +const decompressArchive = ( + data: ArrayBuffer, + url: string, +): Effect.Effect => + Effect.callback((resume) => { + let completed = false; + const complete = (effect: Effect.Effect) => { + if (completed) return; + completed = true; + resume(effect); + }; + try { + zstdDecompress(new Uint8Array(data), (cause, archive) => + complete( + cause === null ? Effect.succeed(archive) : Effect.fail(new DownloadError({ url, cause })), + ), + ); + } catch (cause) { + complete(Effect.fail(new DownloadError({ url, cause }))); + } + return Effect.void; + }); + +const checksumForArchive = (contents: string, archiveName: string): string | undefined => { + for (const line of contents.split(/\r?\n/)) { + const match = line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/i); + if (match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`)) { + return match[1]?.toLowerCase(); + } + } + return undefined; +}; + +const manifestError = (url: string, detail: string): BinaryManifestError => + new BinaryManifestError({ url, detail }); + +const isSlimServiceManifest = (value: unknown): value is SlimServiceManifest => { + if (typeof value !== "object" || value === null) return false; + if (!("service" in value) || typeof value.service !== "string") return false; + if (!("version" in value) || typeof value.version !== "string") return false; + if (!("target" in value) || typeof value.target !== "string") return false; + if (!("entrypoint" in value) || !Array.isArray(value.entrypoint)) return false; + if (!("cmd" in value) || !Array.isArray(value.cmd)) return false; + if ( + "runtime_requires" in value && + value.runtime_requires !== null && + value.runtime_requires !== "glibc" + ) + return false; + if (!("libc" in value) || (value.libc !== null && value.libc !== "glibc")) return false; + if (!("os_floor" in value)) return false; + if (value.os_floor !== null) { + if (typeof value.os_floor !== "object") return false; + if (!("kind" in value.os_floor) || typeof value.os_floor.kind !== "string") return false; + if (!("floor" in value.os_floor)) return false; + if (value.os_floor.floor !== null && typeof value.os_floor.floor !== "string") return false; + if (!("scanned" in value.os_floor) || typeof value.os_floor.scanned !== "number") return false; + if ( + "offender" in value.os_floor && + value.os_floor.offender !== null && + typeof value.os_floor.offender !== "string" + ) + return false; + if ("bundled_glibc" in value.os_floor && typeof value.os_floor.bundled_glibc !== "boolean") + return false; + } + return true; +}; + +const validateManifest = ( + release: NativeReleaseArtifact, + raw: unknown, + platform: { readonly os: string; readonly arch: string }, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): Effect.Effect< + HostCompatibilityRequirement, + BinaryManifestError | BinaryHostCompatibilityError +> => + Effect.gen(function* () { + if (typeof raw !== "object" || raw === null) { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest must be an object")); + } + if (!isSlimServiceManifest(raw)) { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest schema is invalid")); + } + const manifest = raw; + if ( + manifest.service !== release.service || + manifest.version !== release.version || + manifest.target !== release.target + ) { + return yield* Effect.fail( + manifestError( + release.manifestUrl, + "Manifest service/version/target does not match release", + ), + ); + } + if ( + !Array.isArray(manifest.entrypoint) || + !manifest.entrypoint.every((value) => typeof value === "string") || + !Array.isArray(manifest.cmd) || + !manifest.cmd.every((value) => typeof value === "string") + ) { + return yield* Effect.fail( + manifestError(release.manifestUrl, "Manifest entrypoint/cmd must be string arrays"), + ); + } + if (manifest.entrypoint.length === 0 && manifest.cmd.length === 0) { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest has no command")); + } + const runtimeRequires = manifest.runtime_requires ?? null; + const commandPaths = [...manifest.entrypoint, ...manifest.cmd].filter( + (entry) => + entry.startsWith("/") || + entry.includes("/") || + entry.includes("\\") || + entry === "." || + entry === "..", + ); + if (commandPaths.some((entry) => hasTraversalSegment(entry))) { + return yield* Effect.fail( + manifestError(release.manifestUrl, "Manifest command path is unsafe"), + ); + } + const osFloor = manifest.os_floor; + if (osFloor !== null && typeof osFloor !== "object") { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest os_floor is invalid")); + } + if (osFloor !== null && osFloor.kind !== "macos" && osFloor.kind !== "glibc") { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target: release.target, + detail: `Unsupported manifest host kind ${osFloor.kind}`, + }), + ); + } + const hostCompatibility: HostCompatibilityRequirement = { + runtimeRequires, + libc: manifest.libc, + osFloor: + osFloor === null + ? null + : osFloor.kind === "macos" + ? { kind: "macos", floor: osFloor.floor } + : { kind: "glibc", floor: osFloor.floor }, + }; + yield* validateHostCompatibility(release.target, hostCompatibility, platform, spawner); + return hostCompatibility; + }); + +const validateHostCompatibility = ( + target: NativeTarget, + requirement: HostCompatibilityRequirement, + platform: { readonly os: string; readonly arch: string }, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): Effect.Effect => + Effect.gen(function* () { + const requiresGlibc = + requirement.libc === "glibc" || + requirement.runtimeRequires === "glibc" || + requirement.osFloor?.kind === "glibc"; + if (requirement.osFloor?.kind === "macos" && platform.os !== "darwin") { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Manifest requires macOS", + }), + ); + } + if (requiresGlibc && platform.os !== "linux") { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Manifest requires Linux/glibc", + }), + ); + } + const floor = requirement.osFloor?.floor; + if (requiresGlibc) { + const host = yield* Effect.sync(() => { + try { + const report = process.report?.getReport?.(); + if (typeof report !== "object" || report === null || !("header" in report)) { + return undefined; + } + const header = report.header; + if ( + typeof header !== "object" || + header === null || + !("glibcVersionRuntime" in header) || + typeof header.glibcVersionRuntime !== "string" + ) { + return undefined; + } + return header.glibcVersionRuntime; + } catch { + return undefined; + } + }); + if (typeof host !== "string" || host.trim().length === 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Unable to determine host glibc version", + }), + ); + } + if (floor !== null && floor !== undefined) { + const comparison = compareVersions(host, floor); + if (comparison === undefined) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: `Host glibc ${host} or manifest floor ${floor} is not a dotted numeric version`, + }), + ); + } + if (comparison < 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: `Host glibc ${host} is below manifest floor ${floor}`, + }), + ); + } + } + } + if ( + platform.os === "darwin" && + requirement.osFloor?.kind === "macos" && + floor !== null && + floor !== undefined + ) { + const host = yield* spawner.string(ChildProcess.make("sw_vers", ["-productVersion"])).pipe( + Effect.mapError( + (cause) => + new BinaryHostCompatibilityError({ + target, + detail: `Unable to determine macOS version: ${String(cause)}`, + }), + ), + ); + const hostVersion = host.trim().split(/\s+/)[0] ?? ""; + if (hostVersion.length === 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Unable to determine macOS version", + }), + ); + } + const comparison = compareVersions(hostVersion, floor); + if (comparison === undefined) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: `Host macOS ${hostVersion} or manifest floor ${floor} is not a dotted numeric version`, + }), + ); + } + if (comparison < 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: `Host macOS ${hostVersion} is below manifest floor ${floor}`, + }), + ); + } + } + }); + +const parseVersion = (value: string): ReadonlyArray | undefined => { + const parts = value.split("."); + if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) return undefined; + const parsed = parts.map(Number); + return parsed.every(Number.isSafeInteger) ? parsed : undefined; +}; + +const compareVersions = (left: string, right: string): number | undefined => { + const a = parseVersion(left); + const b = parseVersion(right); + if (a === undefined || b === undefined) return undefined; + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + const diff = (a[index] ?? 0) - (b[index] ?? 0); + if (diff !== 0) return diff; + } + return 0; +}; + export class BinaryResolver extends Context.Service< BinaryResolver, { + /** Computes the immutable cache identity without inspecting or changing the filesystem. */ + readonly plan: (spec: BinarySpec) => Effect.Effect; readonly resolveWithMetadata: ( spec: BinarySpec, options?: ResolveBinaryOptions, ) => Effect.Effect< ResolveBinaryResult, - BinaryNotFoundError | DownloadError | ChecksumMismatchError + | BinaryNotFoundError + | DownloadError + | ChecksumMismatchError + | BinaryManifestError + | BinaryRuntimeError + | BinaryHostCompatibilityError >; readonly resolve: ( spec: BinarySpec, - ) => Effect.Effect; + ) => Effect.Effect< + string, + | BinaryNotFoundError + | DownloadError + | ChecksumMismatchError + | BinaryManifestError + | BinaryRuntimeError + | BinaryHostCompatibilityError + >; } >()("local/BinaryResolver") { // Static pure functions — tested in unit tests static cachePath = cachePath; - static legacyExecutablePath = legacyExecutablePath; - static legacyCacheRequiredPaths = legacyCacheRequiredPaths; static make( cacheRoot: string, @@ -165,29 +475,123 @@ export class BinaryResolver extends Context.Service< const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const isCompleteCache = (directory: string) => + const isCacheCompleteMarker = (value: unknown): value is CacheCompleteMarker => { + if (typeof value !== "object" || value === null) return false; + if (!("provider" in value) || typeof value.provider !== "string") return false; + if (!("service" in value) || typeof value.service !== "string") return false; + if (!("version" in value) || typeof value.version !== "string") return false; + if (!("asset" in value) || typeof value.asset !== "string") return false; + if (!("url" in value) || typeof value.url !== "string") return false; + if ( + !("target" in value) || + (value.target !== "darwin-arm64" && + value.target !== "linux-amd64" && + value.target !== "linux-arm64") + ) + return false; + if (!("releaseSet" in value) || value.releaseSet !== "slim-services") return false; + if (!("runtime" in value) || value.runtime !== "native") return false; + if (!("hostCompatibility" in value)) return false; + const host = value.hostCompatibility; + if (typeof host !== "object" || host === null) return false; + if ( + !("runtimeRequires" in host) || + (host.runtimeRequires !== null && host.runtimeRequires !== "glibc") + ) + return false; + if (!("libc" in host) || (host.libc !== null && host.libc !== "glibc")) return false; + if (!("osFloor" in host)) return false; + if (host.osFloor !== null) { + if (typeof host.osFloor !== "object") return false; + if ( + !("kind" in host.osFloor) || + (host.osFloor.kind !== "glibc" && host.osFloor.kind !== "macos") + ) + return false; + if ( + !("floor" in host.osFloor) || + (host.osFloor.floor !== null && typeof host.osFloor.floor !== "string") + ) + return false; + } + return true; + }; + + const isCompleteCache = ( + directory: string, + release: NativeReleaseArtifact, + info: AssetInfo, + platform: { readonly os: string; readonly arch: string }, + ) => Effect.gen(function* () { - if (!(yield* fs.exists(path.join(directory, CACHE_COMPLETE_MARKER)))) { + const marker = yield* fs + .readFileString(path.join(directory, CACHE_COMPLETE_MARKER)) + .pipe(Effect.option); + if (Option.isNone(marker)) return false; + const parsed = yield* Effect.sync(() => { + try { + const value: unknown = JSON.parse(marker.value); + return value; + } catch { + return undefined; + } + }); + if (!isCacheCompleteMarker(parsed)) return false; + if ( + parsed.provider !== release.provider || + parsed.service !== info.service || + parsed.version !== info.version || + parsed.asset !== release.assetName || + parsed.url !== release.downloadUrl || + parsed.target !== info.target || + parsed.releaseSet !== info.releaseSet || + parsed.runtime !== info.runtime + ) { return false; } - const entries = yield* fs.readDirectory(directory); - return entries.some((entry) => entry !== CACHE_COMPLETE_MARKER); - }); + const requiredPaths = [...new Set(release.requiredRuntimePaths)]; + const present = yield* Effect.forEach(requiredPaths, (entry) => + fs.exists(path.join(directory, entry)), + ); + if (!present.every(Boolean)) return false; + yield* validateHostCompatibility( + release.target, + parsed.hostCompatibility, + platform, + spawner, + ); + return true; + }).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(false))); - const isReusableLegacyCache = ( - directory: string, - service: ServiceName, - platformOs: string, - ) => { - const requiredPaths = legacyCacheRequiredPaths(directory, service, platformOs); - return requiredPaths.length === 0 - ? Effect.succeed(false) - : Effect.forEach(requiredPaths, fs.exists).pipe( - Effect.map((results) => results.every(Boolean)), + const resolveRelease = (spec: BinarySpec) => + Effect.gen(function* () { + const platform = yield* detectPlatform; + const release = nativeReleaseForService(spec.service, spec.version, platform); + if (release === undefined) { + return yield* Effect.fail( + new BinaryNotFoundError({ + service: spec.service, + platform: `${platform.os}-${platform.arch}`, + }), ); - }; + } + const info: AssetInfo = { + service: spec.service, + releaseSet: "slim-services", + version: spec.version, + runtime: "native", + target: release.target, + }; + return { platform, release, info }; + }); + + const plan = (spec: BinarySpec): Effect.Effect => + Effect.gen(function* () { + const { info } = yield* resolveRelease(spec); + return cachePath(spec.cacheDir ?? binDir, info); + }); - const cleanupStaleStaging = (directory: string, prefix: string) => + const cleanupStaleEntries = (directory: string, prefix: string) => fs.readDirectory(directory).pipe( Effect.flatMap((entries) => Effect.forEach( @@ -199,7 +603,7 @@ export class BinaryResolver extends Context.Service< Option.match(info.mtime, { onNone: () => Effect.void, onSome: (modifiedAt) => - Date.now() - modifiedAt.getTime() >= STALE_STAGING_AGE_MS + Date.now() - modifiedAt.getTime() >= STALE_PREPARATION_ENTRY_AGE_MS ? fs.remove(stagingPath, { recursive: true, force: true }) : Effect.void, }), @@ -207,18 +611,70 @@ export class BinaryResolver extends Context.Service< Effect.ignore, ); }, - { concurrency: "unbounded" }, + { concurrency: 4 }, ), ), Effect.ignore, ); + const validateExtractedTree = (directory: string) => + Effect.gen(function* () { + const root = yield* fs.realPath(directory); + const entries = yield* fs.readDirectory(directory, { recursive: true }); + for (const entry of entries) { + const candidate = path.join(directory, entry); + const resolved = yield* fs.realPath(candidate).pipe( + Effect.mapError( + () => + new BinaryRuntimeError({ + path: candidate, + detail: "Extracted path cannot be resolved inside private staging", + }), + ), + ); + const relative = path.relative(root, resolved); + if ( + path.isAbsolute(relative) || + relative === ".." || + relative.startsWith(`..${path.sep}`) + ) { + return yield* Effect.fail( + new BinaryRuntimeError({ + path: candidate, + detail: `Extracted path resolves outside private staging: ${entry}`, + }), + ); + } + } + }); + const extractRelease = ( release: NativeReleaseArtifact, destination: string, - platformOs: string, + platform: { readonly os: string; readonly arch: string }, ) => Effect.gen(function* () { + const manifestResponse = yield* httpClient + .get(release.manifestUrl) + .pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail(new DownloadError({ url: release.manifestUrl, cause })), + ), + ); + const manifestText = yield* manifestResponse.text.pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail(new DownloadError({ url: release.manifestUrl, cause })), + ), + ); + const hostCompatibility = yield* Effect.try({ + try: () => { + const parsed: unknown = JSON.parse(manifestText); + return parsed; + }, + catch: (cause) => + manifestError(release.manifestUrl, `Invalid JSON: ${String(cause)}`), + }).pipe(Effect.flatMap((value) => validateManifest(release, value, platform, spawner))); + const tarballResponse = yield* httpClient .get(release.downloadUrl) .pipe( @@ -232,43 +688,57 @@ export class BinaryResolver extends Context.Service< ), ); - const checksumUrl = release.checksumUrl; - if (checksumUrl !== null) { - const checksumResponse = yield* httpClient - .get(checksumUrl) - .pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: checksumUrl, cause })), - ), - ); - const checksumText = yield* checksumResponse.text.pipe( + const checksumResponse = yield* httpClient + .get(release.checksumUrl) + .pipe( Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: checksumUrl, cause })), + Effect.fail(new DownloadError({ url: release.checksumUrl, cause })), ), ); - yield* verifyChecksum(tarball, checksumText, checksumUrl); + const checksumText = yield* checksumResponse.text.pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail(new DownloadError({ url: release.checksumUrl, cause })), + ), + ); + const expected = checksumForArchive(checksumText, `${release.assetName}.tar.zst`); + if (expected === undefined) { + return yield* Effect.fail( + manifestError(release.checksumUrl, "SHA256SUMS has no entry for the archive"), + ); } + yield* verifyChecksum(tarball, expected, release.checksumUrl); - const archivePath = path.join(destination, `_download.${release.archive}`); - yield* fs.writeFile(archivePath, new Uint8Array(tarball)); + const archivePath = path.join(destination, "_download.tar"); + const archive = yield* decompressArchive(tarball, release.downloadUrl); + yield* fs.writeFile(archivePath, archive); - const [command, ...args] = extractCommand( - release.archive, - archivePath, - destination, - platformOs, - release.stripComponents, - ); - if (command === undefined) { + const members = yield* spawner + .string(ChildProcess.make("tar", ["-tf", archivePath])) + .pipe( + Effect.catch((cause) => + Effect.fail( + new DownloadError({ + url: release.downloadUrl, + cause, + }), + ), + ), + ); + const unsafeMember = members + .split(/\r?\n/) + .map((member) => member.trim()) + .find(isUnsafeArchiveMember); + if (unsafeMember !== undefined) { return yield* Effect.fail( new DownloadError({ url: release.downloadUrl, - cause: new Error("No extraction command was configured"), + cause: new Error(`archive member is unsafe: ${unsafeMember}`), }), ); } + const exitCode = yield* spawner - .exitCode(ChildProcess.make(command, args)) + .exitCode(ChildProcess.make("tar", ["-xf", archivePath, "-C", destination])) .pipe( Effect.catchTag("PlatformError", (cause) => Effect.fail(new DownloadError({ url: release.downloadUrl, cause })), @@ -283,82 +753,94 @@ export class BinaryResolver extends Context.Service< ); } + yield* validateExtractedTree(destination); + yield* fs.remove(archivePath).pipe(Effect.ignore); - if (platformOs !== "win32") { - yield* spawner - .exitCode(ChildProcess.make("chmod", ["-R", "u+x", destination])) - .pipe(Effect.ignore); + const requirePostProcess = (name: string, command: ChildProcess.Command) => + Effect.gen(function* () { + const exitCode = yield* spawner.exitCode(command).pipe( + Effect.mapError( + (cause) => + new BinaryRuntimeError({ + path: destination, + detail: `${name} could not run: ${String(cause)}`, + }), + ), + ); + if (exitCode !== 0) { + return yield* Effect.fail( + new BinaryRuntimeError({ + path: destination, + detail: `${name} exited with code ${exitCode}`, + }), + ); + } + }); + + if (platform.os !== "win32") { + yield* requirePostProcess( + "chmod", + ChildProcess.make("chmod", ["-R", "u+x", destination]), + ); } - if (platformOs === "darwin") { - yield* spawner - .exitCode( - ChildProcess.make("find", [ - destination, - "-type", - "f", - "(", - "-perm", - "+111", - "-o", - "-name", - "*.dylib", - ")", - "-exec", - "codesign", - "-f", - "-s", - "-", - "{}", - "+", - ]), - ) - .pipe(Effect.ignore); + if (platform.os === "darwin") { + yield* requirePostProcess( + "codesign", + ChildProcess.make("find", [ + destination, + "-type", + "f", + "(", + "-perm", + "+111", + "-o", + "-name", + "*.dylib", + ")", + "-exec", + "codesign", + "-f", + "-s", + "-", + "{}", + "+", + ]), + ); } - }); - const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => { - const core = Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService(spec.service, spec.version, platform); - if (release === undefined) { + const requiredPaths = [...new Set(release.requiredRuntimePaths)]; + const missing = yield* Effect.forEach(requiredPaths, (entry) => + fs.exists(path.join(destination, entry)), + ).pipe(Effect.map((exists) => requiredPaths.filter((_entry, index) => !exists[index]))); + if (missing.length > 0) { return yield* Effect.fail( - new BinaryNotFoundError({ - service: spec.service, - platform: `${platform.os}-${platform.arch}`, + new BinaryRuntimeError({ + path: destination, + detail: `Manifest runtime paths are missing: ${missing.join(", ")}`, }), ); } + return hostCompatibility; + }); - const info: AssetInfo = { - service: spec.service, - provider: release.provider, - version: spec.version, - assetName: release.assetName, - }; + const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => { + const core = Effect.gen(function* () { + const { platform, release, info } = yield* resolveRelease(spec); const baseDir = spec.cacheDir ?? binDir; const cacheDir = cachePath(baseDir, info); - const legacyDir = legacyCachePath(baseDir, info); const parentDir = path.dirname(cacheDir); const stagingPrefix = `.${release.assetName}.partial-`; - yield* cleanupStaleStaging(parentDir, stagingPrefix); - if (yield* isCompleteCache(cacheDir)) { + const publicationLock = path.join(parentDir, `.${release.assetName}.publication-lock`); + yield* cleanupStaleEntries(parentDir, stagingPrefix); + yield* cleanupStaleEntries(parentDir, path.basename(publicationLock)); + if (yield* isCompleteCache(cacheDir, release, info, platform)) { return { path: cacheDir, downloaded: false, } satisfies ResolveBinaryResult; } - if ( - legacyDir !== undefined && - (yield* isReusableLegacyCache(legacyDir, spec.service, platform.os)) - ) { - return { - path: legacyDir, - downloaded: false, - } satisfies ResolveBinaryResult; - } - yield* fs.makeDirectory(parentDir, { recursive: true }); yield* options?.onDownloadStart ?? Effect.void; @@ -367,7 +849,7 @@ export class BinaryResolver extends Context.Service< prefix: stagingPrefix, }); return yield* Effect.gen(function* () { - yield* extractRelease(release, stagingDir, platform.os); + const hostCompatibility = yield* extractRelease(release, stagingDir, platform); yield* fs.writeFile( path.join(stagingDir, CACHE_COMPLETE_MARKER), new TextEncoder().encode( @@ -377,6 +859,10 @@ export class BinaryResolver extends Context.Service< version: spec.version, asset: release.assetName, url: release.downloadUrl, + target: info.target, + releaseSet: info.releaseSet, + runtime: info.runtime, + hostCompatibility, }), ), ); @@ -388,23 +874,46 @@ export class BinaryResolver extends Context.Service< if (Result.isSuccess(publication)) { return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; } - if (yield* isCompleteCache(cacheDir)) { + if (yield* isCompleteCache(cacheDir, release, info, platform)) { return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; } - // A fully staged replacement is now available, so an incomplete - // destination can be reclaimed without risking the last usable - // cache entry. Retry publication once; persistent filesystem - // failures still surface instead of looping forever. - yield* fs.remove(cacheDir, { recursive: true, force: true }); - const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result); - if (Result.isSuccess(retry)) { - return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; - } - if (yield* isCompleteCache(cacheDir)) { - return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; - } - return yield* Effect.fail(retry.failure); + return yield* Effect.scoped( + Effect.gen(function* () { + const acquirePublicationLock: Effect.Effect = + fs.makeDirectory(publicationLock).pipe( + Effect.retry({ + while: (error) => Predicate.isTagged(error.reason, "AlreadyExists"), + schedule: Schedule.recurs(1_200).pipe( + Schedule.addDelay(() => Effect.succeed(Duration.millis(25))), + ), + }), + ); + yield* Effect.acquireRelease(acquirePublicationLock, () => + fs + .remove(publicationLock, { recursive: true, force: true }) + .pipe(Effect.ignore), + ); + + // The destination may have changed while this resolver was + // waiting to repair it. Revalidate under the publication + // claim before removing anything shared. + if (yield* isCompleteCache(cacheDir, release, info, platform)) { + return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; + } + + yield* fs.remove(cacheDir, { recursive: true, force: true }); + const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result); + if (Result.isSuccess(retry)) { + return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; + } + const retryFailure = retry.failure; + if (yield* isCompleteCache(cacheDir, release, info, platform)) { + return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; + } + return yield* Effect.fail(retryFailure); + }), + ); }).pipe( Effect.ensuring( fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore), @@ -423,6 +932,7 @@ export class BinaryResolver extends Context.Service< }; return { + plan, resolveWithMetadata, resolve: (spec: BinarySpec) => { return Effect.map(resolveWithMetadata(spec), ({ path }) => path); diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts index d0576010bd..5a6c930596 100644 --- a/packages/stack/src/BinaryResolver.unit.test.ts +++ b/packages/stack/src/BinaryResolver.unit.test.ts @@ -3,111 +3,46 @@ import { BinaryResolver } from "./BinaryResolver.ts"; import { nativeReleaseForService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; -const postgresVersion = DEFAULT_VERSIONS.postgres; -const postgrestVersion = DEFAULT_VERSIONS.postgrest; -const authVersion = DEFAULT_VERSIONS.auth; -const authRcVersion = "2.188.0-rc.15"; -const edgeRuntimeVersion = DEFAULT_VERSIONS["edge-runtime"]; - -describe("nativeReleaseForService", () => { - it("constructs postgres URL (appends -cli suffix for native binaries)", () => { - const release = nativeReleaseForService("postgres", postgresVersion, { +describe("slim native release descriptors", () => { + it("uses the frozen slim-services archive, manifest, and checksum names", () => { + const release = nativeReleaseForService("postgrest", DEFAULT_VERSIONS.postgrest, { os: "darwin", arch: "arm64", }); - expect(release?.downloadUrl).toBe( - `https://github.com/supabase/postgres/releases/download/v${postgresVersion}-cli/supabase-postgres-v${postgresVersion}-cli-darwin-arm64.tar.gz`, - ); - expect(release?.checksumUrl).toBe(`${release?.downloadUrl}.sha256`); - expect(release?.stripComponents).toBe(true); - }); - - it("constructs postgrest URL", () => { - const release = nativeReleaseForService("postgrest", postgrestVersion, { - os: "darwin", - arch: "arm64", - }); - expect(release?.downloadUrl).toBe( - `https://github.com/PostgREST/postgrest/releases/download/v${postgrestVersion}/postgrest-v${postgrestVersion}-macos-aarch64.tar.xz`, - ); - }); - - it("constructs postgrest Windows URL with .zip extension", () => { - const release = nativeReleaseForService("postgrest", postgrestVersion, { - os: "win32", - arch: "x64", - }); - expect(release?.downloadUrl).toBe( - `https://github.com/PostgREST/postgrest/releases/download/v${postgrestVersion}/postgrest-v${postgrestVersion}-windows-x86-64.zip`, - ); - expect(release?.archive).toBe("zip"); - }); - - it("constructs auth URL for rc releases", () => { - const release = nativeReleaseForService("auth", authRcVersion, { - os: "linux", - arch: "arm64", - }); - expect(release?.downloadUrl).toBe( - `https://github.com/supabase/auth/releases/download/rc${authRcVersion}/auth-v${authRcVersion}-arm64.tar.gz`, - ); - }); - - it("constructs edge-runtime URL", () => { - const release = nativeReleaseForService("edge-runtime", edgeRuntimeVersion, { - os: "darwin", - arch: "arm64", + expect(release).toMatchObject({ + releaseTag: "postgrest-v16.1", + target: "darwin-arm64", + archive: "tar.zst", + downloadUrl: + "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.tar.zst", + manifestUrl: + "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.manifest.json", + checksumUrl: + "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/SHA256SUMS", }); - expect(release?.downloadUrl).toBe( - `https://github.com/supabase/edge-runtime/releases/download/v${edgeRuntimeVersion}/edge-runtime-v${edgeRuntimeVersion}-aarch64-darwin.tar.gz`, - ); }); - it("returns no native release for unsupported platforms", () => { + it("only exposes the three supported native targets", () => { expect( - nativeReleaseForService("auth", authVersion, { os: "win32", arch: "arm64" }), + nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "win32", arch: "x64" }), ).toBeUndefined(); + expect( + nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "linux", arch: "x64" })?.target, + ).toBe("linux-amd64"); }); }); describe("BinaryResolver.cachePath", () => { - it("constructs cache path", () => { + it("includes service, release provider, version, and target identity", () => { const path = BinaryResolver.cachePath("/home/user/.supabase/bin", { service: "postgres", - provider: "github.com/supabase/postgres", - version: postgresVersion, - assetName: "darwin-arm64", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgres, + runtime: "native", + target: "linux-amd64", }); expect(path).toBe( - `/home/user/.supabase/bin/postgres/github.com_supabase_postgres/${postgresVersion}/darwin-arm64`, - ); - }); -}); - -describe("BinaryResolver.legacyExecutablePath", () => { - it("recognizes the executable suffix used by Windows archives", () => { - expect(BinaryResolver.legacyExecutablePath("C:/cache/postgrest", "postgrest", "win32")).toBe( - "C:/cache/postgrest/postgrest.exe", - ); - }); - - it("keeps Unix executable names unchanged", () => { - expect(BinaryResolver.legacyExecutablePath("/cache/postgrest", "postgrest", "linux")).toBe( - "/cache/postgrest/postgrest", - ); - }); -}); - -describe("BinaryResolver.legacyCacheRequiredPaths", () => { - it("requires the Postgres initialization payload as well as the executable", () => { - expect(BinaryResolver.legacyCacheRequiredPaths("/cache/postgres", "postgres", "linux")).toEqual( - [ - "/cache/postgres/bin/postgres", - "/cache/postgres/bin/pg_isready", - "/cache/postgres/bin/psql", - "/cache/postgres/share/supabase-cli/bin/supabase-postgres-init.sh", - "/cache/postgres/lib", - ], + `/home/user/.supabase/bin/slim-services/postgres/${DEFAULT_VERSIONS.postgres}/native/linux-amd64`, ); }); }); diff --git a/packages/stack/src/ContainerRuntime.integration.test.ts b/packages/stack/src/ContainerRuntime.integration.test.ts new file mode 100644 index 0000000000..15cf536848 --- /dev/null +++ b/packages/stack/src/ContainerRuntime.integration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { selectStackRuntime, selectStackRuntimeForPlatform } from "./ContainerRuntime.ts"; + +const runtimeSpawner = (availability: Readonly>) => { + const commands: string[] = []; + return { + commands, + layer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = Predicate.isTagged(command, "StandardCommand") ? command.command : ""; + commands.push(executable); + const exitCode = yield* Deferred.make(); + yield* Deferred.succeed( + exitCode, + ChildProcessSpawner.ExitCode(availability[executable] === true ? 0 : 1), + ); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitCode), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ), + }; +}; + +describe("stack runtime selection", () => { + it.effect("uses Docker mode when the Docker daemon is usable", () => { + const spawner = runtimeSpawner({ docker: true }); + return Effect.gen(function* () { + expect(yield* selectStackRuntime()).toEqual({ + mode: "docker", + containerRuntime: "docker", + }); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("uses Podman for Docker mode when Docker is unavailable", () => { + const spawner = runtimeSpawner({ podman: true }); + return Effect.gen(function* () { + expect(yield* selectStackRuntime()).toEqual({ + mode: "docker", + containerRuntime: "podman", + }); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("uses native mode when no container runtime is usable", () => { + const spawner = runtimeSpawner({}); + return Effect.gen(function* () { + expect(yield* selectStackRuntimeForPlatform({ os: "linux", arch: "x64" })).toEqual({ + mode: "native", + containerRuntime: null, + }); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("does not probe container runtimes when native mode is explicit", () => { + const spawner = runtimeSpawner({ docker: true }); + return Effect.gen(function* () { + expect(yield* selectStackRuntimeForPlatform({ os: "linux", arch: "x64" }, "native")).toEqual({ + mode: "native", + containerRuntime: null, + }); + expect(spawner.commands).toEqual([]); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("rejects explicit Docker mode when neither runtime is usable", () => { + const spawner = runtimeSpawner({}); + return Effect.gen(function* () { + const error = yield* selectStackRuntime("docker").pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "StackBuildError", + reason: "docker_not_running", + }); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("rejects automatic fallback when the host has no native artifacts", () => { + const spawner = runtimeSpawner({}); + return Effect.gen(function* () { + const error = yield* selectStackRuntimeForPlatform({ os: "win32", arch: "x64" }).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "StackBuildError", + reason: "docker_not_running", + }); + expect(error.detail).toContain("win32-x64"); + }).pipe(Effect.provide(spawner.layer)); + }); +}); diff --git a/packages/stack/src/ContainerRuntime.ts b/packages/stack/src/ContainerRuntime.ts new file mode 100644 index 0000000000..3f1ff135ce --- /dev/null +++ b/packages/stack/src/ContainerRuntime.ts @@ -0,0 +1,100 @@ +import { Effect, Exit } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { StackBuildError } from "./errors.ts"; +import { detectPlatform, nativeTargetForPlatform, type PlatformInfo } from "./Platform.ts"; +import type { StackMode } from "./StackConfig.ts"; + +export type ContainerRuntime = "docker" | "podman"; + +export type StackRuntimeSelection = + | { readonly mode: "native"; readonly containerRuntime: null } + | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime }; + +const probeContainerRuntime = ( + runtime: ContainerRuntime, +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const result = yield* Effect.exit( + spawner.exitCode(ChildProcess.make(runtime, ["info"])).pipe(Effect.timeout("30 seconds")), + ); + return Exit.isSuccess(result) && result.value === 0; + }); + +export const validateStackRuntime = ( + selection: StackRuntimeSelection, +): Effect.Effect< + StackRuntimeSelection, + StackBuildError, + ChildProcessSpawner.ChildProcessSpawner +> => + selection.mode === "native" + ? Effect.succeed(selection) + : probeContainerRuntime(selection.containerRuntime).pipe( + Effect.flatMap((usable) => + usable + ? Effect.succeed(selection) + : Effect.fail( + new StackBuildError({ + detail: `Docker mode requires a usable ${selection.containerRuntime} runtime. Restore or start the persisted ${selection.containerRuntime} runtime and retry, or delete and recreate the stack (removing its managed data) to choose another execution mode.`, + reason: "docker_not_running", + }), + ), + ), + ); + +export const selectStackRuntimeForPlatform = ( + platform: PlatformInfo, + requestedMode?: StackMode, +): Effect.Effect => + Effect.gen(function* () { + if (requestedMode === "native") { + if (nativeTargetForPlatform(platform) !== undefined) { + return { mode: "native", containerRuntime: null }; + } + return yield* Effect.fail( + new StackBuildError({ + detail: `Native mode is unavailable on ${platform.os}-${platform.arch}. Use a supported Linux or Apple silicon macOS host, or install and start Docker or Podman.`, + reason: "invalid_config", + }), + ); + } + + const runtimes = ["docker", "podman"] as const satisfies ReadonlyArray; + const probes = yield* Effect.all( + runtimes.map((runtime) => + probeContainerRuntime(runtime).pipe(Effect.map((usable) => [runtime, usable] as const)), + ), + { concurrency: "unbounded" }, + ); + const selected = probes.find(([, usable]) => usable)?.[0]; + if (selected !== undefined) { + return { mode: "docker", containerRuntime: selected }; + } + + if (requestedMode === "docker") { + return yield* Effect.fail( + new StackBuildError({ + detail: "Docker mode requires a usable Docker or Podman runtime", + reason: "docker_not_running", + }), + ); + } + + if (nativeTargetForPlatform(platform) !== undefined) { + return { mode: "native", containerRuntime: null }; + } + return yield* Effect.fail( + new StackBuildError({ + detail: `No usable Docker or Podman runtime was found, and native mode is unavailable on ${platform.os}-${platform.arch}. Install and start Docker or Podman.`, + reason: "docker_not_running", + }), + ); + }); + +export const selectStackRuntime = ( + requestedMode?: StackMode, +): Effect.Effect => + detectPlatform.pipe( + Effect.flatMap((platform) => selectStackRuntimeForPlatform(platform, requestedMode)), + ); diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts index a0e058312e..3098e2b89e 100644 --- a/packages/stack/src/DaemonProtocol.ts +++ b/packages/stack/src/DaemonProtocol.ts @@ -5,6 +5,7 @@ const DaemonErrorCodeSchema = Schema.Literals([ "SERVICE_NOT_READY", "STACK_READINESS_TIMEOUT", "STACK_BUILD_ERROR", + "STACK_NOT_RUNNING", ]); const StackBuildReasonSchema = Schema.Literals([ @@ -38,6 +39,7 @@ export const DaemonErrorResponseSchema = Schema.Struct({ service: Schema.optionalKey(Schema.String), exitCode: Schema.optionalKey(Schema.Number), timeoutMs: Schema.optionalKey(Schema.Number), + phase: Schema.optionalKey(Schema.String), reason: Schema.optionalKey(StackBuildReasonSchema), }); diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts index 120c13b40f..06dc721a90 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { ServiceNotFoundError, type LogEntry } from "@supabase/process-compose"; -import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Predicate, Stream } from "effect"; +import { HttpServer } from "effect/unstable/http"; import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; @@ -188,16 +189,12 @@ function buildDaemonLayer( ) as Layer.Layer; } -function getUrl(address: { - readonly _tag: string; - readonly hostname?: string; - readonly port?: number; -}): string { - if (address._tag === "TcpAddress") { +function getUrl(address: HttpServer.Address): string { + if (Predicate.isTagged(address, "TcpAddress")) { const host = address.hostname === "0.0.0.0" ? "127.0.0.1" : address.hostname; return `http://${host}:${address.port}`; } - throw new Error(`Unexpected address type: ${address._tag}`); + throw new Error("Unexpected address type"); } // --------------------------------------------------------------------------- diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 0c56371133..6c5779d5a9 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Exit, Layer, Context, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, Context, Stream } from "effect"; import { Headers, HttpRouter, @@ -11,7 +11,10 @@ import type { ControlOwnerStatus, DaemonErrorResponse } from "./DaemonProtocol.t import { FunctionsReloadConfigSchema } from "./functions.ts"; import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; import { ReadyOptionsSchema } from "./StackConfig.ts"; -import { managedStackLaunchSchema, type ManagedStackLaunch } from "./managed/document.ts"; +import { + managedStackLaunchUpdateSchema, + type ManagedStackLaunchUpdate, +} from "./managed/document.ts"; // --------------------------------------------------------------------------- // Service @@ -35,7 +38,7 @@ export class DaemonServer extends Context.Service< }), options: { readonly includeOwnerRoute?: boolean; - readonly launchUpdate?: (launch: ManagedStackLaunch) => Effect.Effect; + readonly launchUpdate?: (launch: ManagedStackLaunchUpdate) => Effect.Effect; /** Supervisor-owned shutdown callbacks already stop the local stack. */ readonly stopOnShutdown?: boolean; } = {}, @@ -45,9 +48,10 @@ export class DaemonServer extends Context.Service< Effect.gen(function* () { const stack = yield* Stack; const server = yield* HttpServer.HttpServer; + const scope = yield* Effect.scope; const shutdownDeferred = yield* Deferred.make(); const textEncoder = new TextEncoder(); - const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 500) => + const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 409 | 500) => HttpServerResponse.jsonUnsafe(body, { status }); const notFoundResponse = (name: string) => errorResponse( @@ -73,6 +77,15 @@ export class DaemonServer extends Context.Service< }, 500, ); + const notRunningResponse = (phase: string) => + errorResponse( + { + code: "STACK_NOT_RUNNING", + error: `Stack is not running (phase: ${phase})`, + phase, + }, + 409, + ); const invalidReloadPayloadResponse = () => errorResponse( { code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" }, @@ -101,33 +114,21 @@ export class DaemonServer extends Context.Service< // the socket. Deferred.succeed(shutdownDeferred, void 0).pipe( Effect.delay("25 millis"), - Effect.forkDetach, + Effect.forkIn(scope, { startImmediately: true, uninterruptible: true }), + Effect.asVoid, ), ), ), ); - const shutdownResult = yield* Effect.cached( + const shutdownFiber = yield* Effect.cached( Effect.uninterruptible( - Effect.gen(function* () { - const result = yield* Deferred.make>(); - yield* shutdownTransaction.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.succeed(result, exit)), - Effect.forkDetach, - ); - return result; + Effect.forkIn(shutdownTransaction, scope, { + startImmediately: true, + uninterruptible: true, }), ), ); - const beginShutdown = shutdownResult.pipe( - Effect.flatMap((result) => - Deferred.await(result).pipe( - Effect.flatMap((exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ), - ), - ), - ); + const beginShutdown = shutdownFiber.pipe(Effect.flatMap(Fiber.join)); const terminalReadinessResponse = (target: string, timeoutMs: number, detail: string) => beginShutdown.pipe(Effect.as(readinessTimeoutResponse(target, timeoutMs, detail))); @@ -178,8 +179,9 @@ export class DaemonServer extends Context.Service< "POST", "/managed/launch", Effect.gen(function* () { - const launch = - yield* HttpServerRequest.schemaBodyJson(managedStackLaunchSchema); + const launch = yield* HttpServerRequest.schemaBodyJson( + managedStackLaunchUpdateSchema, + ); yield* launchUpdate(launch); return HttpServerResponse.jsonUnsafe({ ok: true }); }), @@ -329,6 +331,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), @@ -377,6 +382,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), ), ), @@ -397,6 +405,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), @@ -424,6 +435,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), @@ -451,6 +465,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), diff --git a/packages/stack/src/HttpTransportClient.integration.test.ts b/packages/stack/src/HttpTransportClient.integration.test.ts new file mode 100644 index 0000000000..6d1fcad6b3 --- /dev/null +++ b/packages/stack/src/HttpTransportClient.integration.test.ts @@ -0,0 +1,97 @@ +import { Effect, Fiber, ManagedRuntime } from "effect"; +import type { Socket } from "node:net"; +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, test } from "vitest"; +import { HttpTransportClient, httpTransportClientLayer } from "./HttpTransportClient.ts"; +import type { ControlEndpoint } from "./managed/control.ts"; + +const endpointFor = (server: Server): ControlEndpoint => { + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Expected TCP address"); + return { + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }; +}; + +const listen = (server: Server): Promise => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + +const close = (server: Server): Promise => + new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +}; + +describe("HttpTransportClient", () => { + let server: Server | undefined; + let activeSocket: Socket | undefined; + + afterEach(async () => { + activeSocket?.destroy(); + if (server !== undefined) await close(server); + activeSocket = undefined; + server = undefined; + }); + + test("closes an unanswered request when its fiber is interrupted", async () => { + let requestArrived!: () => void; + const requestReady = new Promise((resolve) => { + requestArrived = resolve; + }); + let closed = false; + let connectionClosed!: () => void; + const connectionClosedPromise = new Promise((resolve) => { + connectionClosed = () => { + closed = true; + resolve(); + }; + }); + + server = createServer((_request, response) => { + const socket = response.socket; + if (socket === null) throw new Error("Expected request socket"); + activeSocket = socket; + requestArrived(); + socket.once("close", connectionClosed); + }); + await listen(server); + + const runtime = ManagedRuntime.make(httpTransportClientLayer); + try { + const fiber = runtime.runFork( + Effect.gen(function* () { + const client = yield* HttpTransportClient; + yield* client.request(endpointFor(server!), "/never"); + }), + ); + await requestReady; + await runtime.runPromise(Fiber.interrupt(fiber)); + await withTimeout(connectionClosedPromise, 5_000); + expect(closed).toBe(true); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/packages/stack/src/HttpTransportClient.ts b/packages/stack/src/HttpTransportClient.ts index d9b38a7717..e65a7f3c2a 100644 --- a/packages/stack/src/HttpTransportClient.ts +++ b/packages/stack/src/HttpTransportClient.ts @@ -22,13 +22,14 @@ export class HttpTransportClient extends Context.Service< export const httpTransportClientLayer = Layer.succeed(HttpTransportClient, { request: (endpoint, path, init) => Effect.tryPromise({ - try: () => + try: (signal) => fetch(`${endpoint.url}${path}`, { ...init, - signal: - init?.signal == null - ? AbortSignal.timeout(30_000) - : AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]), + signal: AbortSignal.any( + init?.signal === undefined || init.signal === null + ? [signal, AbortSignal.timeout(30_000)] + : [signal, init.signal], + ), }), catch: (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 8897c679b9..4811af7bfb 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -1,17 +1,25 @@ import { LogBuffer, Orchestrator } from "@supabase/process-compose"; import { ServiceNotFoundError } from "@supabase/process-compose"; -import type { ResolvedGraph, ServiceReadyError } from "@supabase/process-compose"; +import type { + LogEntry, + ResolvedGraph, + ServiceReadyError, + ServiceState, +} from "@supabase/process-compose"; import { Context, Deferred, Duration, Effect, Equal, + Exit, FileSystem, Layer, + Match, Path, Ref, Schema, + Scope, Semaphore, Stream, SubscriptionRef, @@ -41,8 +49,8 @@ import { StackServiceActivator, } from "./ServiceActivation.ts"; import { portFieldsForService } from "./ServicePorts.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import type { PreparedStackArtifacts } from "./StackPreparation.ts"; +import { preparationClosure, StackPreparation } from "./StackPreparation.ts"; +import type { PreparedStackArtifacts, StackPreparationInput } from "./StackPreparation.ts"; import { enabledServicesForConfig, StackBuilder, @@ -57,18 +65,54 @@ import { Stack } from "./Stack.ts"; import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; -type LifecyclePhase = - | "idle" - | "preparing" - | "prepared" - | "starting" - | "running" - | "stopping" - | "stopped" - | "disposed"; +type LifecyclePhase = "idle" | "starting" | "running" | "stopping" | "stopped" | "disposed"; type StackService = typeof Stack.Service; +const READINESS_DIAGNOSTIC_LOG_LIMIT = 20; +const READINESS_DIAGNOSTIC_LINE_LIMIT = 512; + +/** @internal Enriches a readiness timeout without changing diagnostic failure semantics. */ +export const attachReadinessDiagnostics = ( + error: StackReadinessError, + states: Effect.Effect>, + logs: Effect.Effect>, +): Effect.Effect => + Effect.gen(function* () { + const [serviceStates, entries] = yield* Effect.all([states, logs]); + const nonReadyStates = serviceStates.filter( + (state) => + state.status !== "Healthy" && !(state.status === "Stopped" && state.exitCode === 0), + ); + const stateDetail = + nonReadyStates.length === 0 + ? "none" + : nonReadyStates + .map((state) => { + const errorDetail = + state.error === null + ? "" + : `, error=${state.error.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`; + return `${state.name}: ${state.status} (desired=${state.desired}, restarts=${state.restartCount}${errorDetail})`; + }) + .join("; "); + const logDetail = + entries.length === 0 + ? "none" + : entries + .map( + (entry) => + `[${entry.service}/${entry.stream}] ${entry.line.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`, + ) + .join("\n"); + + return new StackReadinessError({ + target: error.target, + timeoutMs: error.timeoutMs, + detail: `${error.detail}\nNon-ready services: ${stateDetail}\nRecent logs:\n${logDetail}`, + }); + }); + /** Private signal used by the Promise adapter to close its enclosing managed runtime. */ export class LocalStackLifecycle extends Context.Service< LocalStackLifecycle, @@ -123,7 +167,7 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { storage: `${apiUrl}/storage/v1`, storage_s3: `${apiUrl}/storage/v1/s3`, }), - ...(config.imgproxy === false || config.startupMode === "lazy" + ...(config.imgproxy === false || config.servicePolicies.imgproxy !== "eager" ? {} : { imgproxy: `http://127.0.0.1:${config.imgproxy.port}` }), ...(config.mailpit === false @@ -182,6 +226,7 @@ export const localStackLayer = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const scope = yield* Effect.scope; + const preparationScope = yield* Scope.fork(scope, "parallel"); const info = stackInfoFor(config); const enabledServices = enabledServicesForConfig(config); @@ -209,6 +254,29 @@ export const localStackLayer = ( : [...current, nextState]; }); + const markDownloading = (service: ServiceName) => + SubscriptionRef.update(stateRef, (current) => { + const index = current.findIndex((entry) => entry.name === service); + if (index === -1 || current[index]?.status === "Downloading") return current; + return current.map((entry, entryIndex) => + entryIndex === index + ? new StackServiceState({ ...entry, status: "Downloading" }) + : entry, + ); + }); + + const restoreStateIfDownloading = ( + service: ServiceName, + previous: StackServiceState | undefined, + ) => + previous === undefined + ? Effect.void + : SubscriptionRef.update(stateRef, (current) => { + const index = current.findIndex((entry) => entry.name === service); + if (index === -1 || current[index]?.status !== "Downloading") return current; + return current.map((entry, entryIndex) => (entryIndex === index ? previous : entry)); + }); + const syncProjectedStates = ( orchestrator: Orchestrator["Service"], serviceProjection: StackServiceProjectionCatalog, @@ -241,36 +309,135 @@ export const localStackLayer = ( return service; }); - let preparedArtifacts: PreparedStackArtifacts | undefined; - let prepareDeferred: Deferred.Deferred | undefined; + let plannedArtifacts: PreparedStackArtifacts | undefined; + let planDeferred: Deferred.Deferred | undefined; + const preparedResolutions: Partial = {}; + const preparationInFlight = new Map< + string, + Deferred.Deferred + >(); let runtimeState: RuntimeState | undefined; let runtimeDeferred: Deferred.Deferred | undefined; let exactCleanupTargets: CleanupTargets | undefined; - const ensurePrepared = Effect.suspend(() => { - if (preparedArtifacts !== undefined) { - return Effect.succeed(preparedArtifacts); - } - if (prepareDeferred !== undefined) { - return Deferred.await(prepareDeferred); - } - - const deferred = Deferred.makeUnsafe(); - prepareDeferred = deferred; + const preparationInput = ( + services: ReadonlyArray, + ): Effect.Effect => { + const shared = { + services, + enabledServices, + versions: versionsForConfig(config), + }; + return config.runtime.mode === "native" + ? Effect.succeed({ ...shared, mode: "native" }) + : Effect.succeed({ + ...shared, + mode: "docker", + containerRuntime: config.runtime.containerRuntime, + }); + }; - const effect = Effect.gen(function* () { - yield* validateResolvedConfig(config); - yield* Ref.set(phaseRef, "preparing"); + const ensurePlanned = Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( + new StackBuildError({ + detail: "Cannot plan stack assets after stack disposal has begun", + }), + ); + } + if (plannedArtifacts !== undefined) return Effect.succeed(plannedArtifacts); + if (planDeferred !== undefined) return restore(Deferred.await(planDeferred)); + const deferred = Deferred.makeUnsafe(); + planDeferred = deferred; + const effect = Effect.gen(function* () { + yield* validateResolvedConfig(config); + const input = yield* preparationInput(enabledServicesForConfig(config)); + return yield* preparation.plan(input).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to plan stack assets", + cause, + reason: "asset_preparation", + }), + ), + ); + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + plannedArtifacts = value; + }), + ), + Effect.ensuring(Effect.sync(() => (planDeferred = undefined))), + ); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); - let prepared: PreparedStackArtifacts | undefined; - yield* preparation - .prepareEvents({ - mode: config.mode, - services: enabledServicesForConfig(config), - versions: versionsForConfig(config), - }) - .pipe( - Stream.mapError( + const prepareServices = (services: ReadonlyArray) => + Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( + new StackBuildError({ + detail: "Cannot prepare stack assets after disposal has begun", + }), + ); + } + const targets = [ + ...new Set( + services.flatMap((service) => + activationTargetsForService(enabledServices, service), + ), + ), + ]; + const preparationTargets = preparationClosure(targets, enabledServices); + const pending = preparationTargets.filter( + (service) => preparedResolutions[service] === undefined, + ); + if (pending.length === 0) { + return Effect.succeed({ + resolutions: preparedResolutions, + } satisfies PreparedStackArtifacts); + } + const key = pending.toSorted().join(","); + const existing = preparationInFlight.get(key); + if (existing !== undefined) return restore(Deferred.await(existing)); + const previousStates = new Map( + preparationTargets.flatMap((service) => { + const state = SubscriptionRef.getUnsafe(stateRef).find( + (entry) => entry.name === service, + ); + return state === undefined || state.status === "Downloading" + ? [] + : [[service, state] as const]; + }), + ); + const deferred = Deferred.makeUnsafe(); + preparationInFlight.set(key, deferred); + const effect = preparationInput(pending).pipe( + Effect.flatMap((input) => + Stream.runFoldEffect( + preparation.prepareEvents(input), + () => ({ resolutions: {} }) satisfies PreparedStackArtifacts, + (current, event) => + Match.valueTags(event, { + ServiceDownloadStarted: (download) => + markDownloading(download.service).pipe(Effect.as(current)), + ServiceDownloadFinished: (download) => + restoreStateIfDownloading( + download.service, + previousStates.get(download.service), + ).pipe(Effect.as(current)), + PreparationCompleted: (completed) => Effect.succeed(completed.artifacts), + }), + ), + ), + Effect.mapError( (cause) => new StackBuildError({ detail: "Failed to prepare stack assets", @@ -281,133 +448,108 @@ export const localStackLayer = ( : "asset_preparation", }), ), - ) - .pipe( - Stream.runForEach((event) => { - switch (event._tag) { - case "ServiceDownloadStarted": - return updateState( - new StackServiceState({ - name: event.service, - status: "Downloading", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - case "ServiceDownloadFinished": - return updateState( - new StackServiceState({ - name: event.service, - status: "Pending", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - case "PreparationCompleted": - return Effect.sync(() => { - prepared = event.artifacts; - }); - } - }), + Effect.tapError(() => + Effect.forEach( + preparationTargets, + (service) => { + return restoreStateIfDownloading(service, previousStates.get(service)); + }, + { discard: true, concurrency: "unbounded" }, + ), + ), + Effect.tap((value) => + Effect.sync(() => Object.assign(preparedResolutions, value.resolutions)), + ), + Effect.ensuring(Effect.sync(() => preparationInFlight.delete(key))), ); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); - if (prepared === undefined) { - return yield* Effect.fail( + const ensureRuntime = Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( new StackBuildError({ - detail: "Stack preparation completed without prepared artifacts", + detail: "Cannot ensure stack runtime after stack disposal has begun", }), ); } + if (runtimeState !== undefined) { + return Effect.succeed(runtimeState); + } + if (runtimeDeferred !== undefined) return restore(Deferred.await(runtimeDeferred)); - yield* Ref.set(phaseRef, "prepared"); - return prepared; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - preparedArtifacts = value; - }), - ), - Effect.onError(() => Ref.set(phaseRef, "idle")), - Effect.ensuring( - Effect.sync(() => { - prepareDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); + const deferred = Deferred.makeUnsafe(); + runtimeDeferred = deferred; - const ensureRuntime = Effect.suspend(() => { - if (runtimeState !== undefined) { - return Effect.succeed(runtimeState); - } - if (runtimeDeferred !== undefined) { - return Deferred.await(runtimeDeferred); - } + const effect = Effect.gen(function* () { + const prepared = yield* ensurePlanned; + const { graph, serviceProjection, cleanupTargets } = yield* builder + .build(config, prepared) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); + const graphServices = new Set(graph.startOrder.map((definition) => definition.name)); + const missingEnabledService = enabledServices.find( + (service) => !graphServices.has(service), + ); + if (missingEnabledService !== undefined) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Prepared graph does not contain enabled service ${missingEnabledService}`, + }), + ); + } + exactCleanupTargets = cleanupTargets; - const deferred = Deferred.makeUnsafe(); - runtimeDeferred = deferred; + const orchLayer = Orchestrator.layer(graph).pipe( + Layer.provide(Layer.succeed(LogBuffer, logBuffer)), + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + const orchServices = yield* Layer.buildWithScope(orchLayer, scope); + const orchestrator = Context.get(orchServices, Orchestrator); - const effect = Effect.gen(function* () { - const prepared = yield* ensurePrepared; - const { graph, serviceProjection, cleanupTargets } = yield* builder.build( - config, - prepared, - ); - exactCleanupTargets = cleanupTargets; + yield* syncProjectedStates(orchestrator, serviceProjection); + yield* orchestrator.allStateChanges().pipe( + Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), + Effect.ignore, + Effect.forkIn(scope), + ); - const orchLayer = Orchestrator.layer(graph).pipe( - Layer.provide(Layer.succeed(LogBuffer, logBuffer)), - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ); - const orchServices = yield* Layer.buildWithScope(orchLayer, scope); - const orchestrator = Context.get(orchServices, Orchestrator); - - yield* syncProjectedStates(orchestrator, serviceProjection); - yield* orchestrator.allStateChanges().pipe( - Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), - Effect.ignore, - Effect.forkIn(scope), + return { + orchestrator, + graph, + serviceProjection, + } satisfies RuntimeState; + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + runtimeState = value; + }), + ), + Effect.ensuring( + Effect.sync(() => { + runtimeDeferred = undefined; + }), + ), ); - return { - orchestrator, - graph, - serviceProjection, - } satisfies RuntimeState; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - runtimeState = value; - }), - ), - Effect.ensuring( - Effect.sync(() => { - runtimeDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); let disposed = false; let disposing = false; const runtimeHost = Effect.gen(function* () { - const prepared = yield* ensurePrepared; + const prepared = yield* ensurePlanned; const platform = yield* detectPlatform; const edgeRuntimeResolution = prepared.resolutions["edge-runtime"]; return { @@ -485,7 +627,11 @@ export const localStackLayer = ( const syncRuntimeProjectedStates = (runtime: RuntimeState) => syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); const serviceStartOptions = { - beforeStart: (name: string) => portLease.reserve(portFieldsForService(name)), + // Reservation may yield while disposal flips the lifecycle state. + beforeStart: (name: string) => + portLease + .reserve(portFieldsForService(name)) + .pipe(Effect.andThen(requireMutable(`start service ${name}`))), beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), }; const knownServiceError = (service: string, cause: ServiceNotFoundError) => @@ -605,23 +751,40 @@ export const localStackLayer = ( const disposeOnce = () => Effect.suspend(() => { disposing = true; - return Effect.gen(function* () { + const preparationError = new StackBuildError({ + detail: "Stack disposed during asset preparation", + }); + const failInFlight: Effect.Effect = Effect.gen(function* () { + if (planDeferred !== undefined) { + yield* Deferred.fail(planDeferred, preparationError); + } + for (const deferred of preparationInFlight.values()) { + yield* Deferred.fail(deferred, preparationError); + } + if (runtimeDeferred !== undefined) { + yield* Deferred.fail(runtimeDeferred, preparationError); + } + }); + const cleanup: Effect.Effect = Effect.gen(function* () { if (disposed) { return; } disposed = true; yield* Ref.set(phaseRef, "stopping"); + yield* Scope.close(preparationScope, Exit.void); yield* cleanupLocalStackResources({ stop: () => runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, config, }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))), Effect.ensuring(portLease.releaseAll), Effect.ensuring(Ref.set(phaseRef, "disposed")), ); }).pipe(withLifecycleLock); + return failInFlight.pipe(Effect.andThen(cleanup)); }).pipe( Effect.ensuring(Deferred.succeed(disposedSignal, undefined).pipe(Effect.asVoid)), Effect.uninterruptible, @@ -653,18 +816,34 @@ export const localStackLayer = ( }), ); }; + const readinessErrorWithDiagnostics = ( + error: StackReadinessError, + ): Effect.Effect => + runtimeState === undefined + ? Effect.succeed(error) + : attachReadinessDiagnostics( + error, + runtimeState.orchestrator.getAllStates(), + logBuffer.historyAll(READINESS_DIAGNOSTIC_LOG_LIMIT), + ); const cleanupOnReadinessFailure = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTag("StackReadinessError", (error) => - disposeOnce().pipe(Effect.andThen(Effect.fail(error))), + Effect.catchIf( + (error): error is StackReadinessError => error instanceof StackReadinessError, + (error) => + readinessErrorWithDiagnostics(error).pipe( + Effect.flatMap(Effect.fail), + Effect.ensuring(disposeOnce()), + ), ), ); yield* Effect.addFinalizer(disposeOnce); const activateService = (name: ServiceName) => Effect.gen(function* () { + yield* requireMutable(`activate service ${name}`); yield* requireRunningPhase; const service = yield* requireKnownServiceName(name); const existing = yield* inspectStartedTargets(service); @@ -684,6 +863,7 @@ export const localStackLayer = ( ); return; } + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireRunningPhase; const concurrentlyStarted = yield* inspectStartedTargets(service); @@ -708,13 +888,19 @@ export const localStackLayer = ( yield* Ref.set(phaseRef, "starting"); const runtime = yield* ensureRuntime; yield* configureFunctions(config, yield* Ref.get(functionsBundleRef)); - serviceStartupBegan = true; - if (config.startupMode === "lazy") { + const eager = eagerServices(enabledServices, config.servicePolicies); + const allServicesEager = eager.length === enabledServices.length; + // The all-eager case can use one topological whole-graph start; + // mixed policies need the more general per-service preparation path. + if (!allServicesEager) { const readiness: Array> = []; + yield* prepareServices(["postgres", ...eager]); + yield* requireMutable("start"); if ( runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") ) { + serviceStartupBegan = true; yield* runtime.orchestrator .startService("postgres-init", serviceStartOptions) .pipe( @@ -732,27 +918,39 @@ export const localStackLayer = ( ), ); } - for (const service of eagerServices(enabledServices)) { + for (const service of eager) { + yield* requireMutable("start"); + serviceStartupBegan = true; const started = yield* beginStartTargets( service, - new Set(lifecycleTargetsForService(enabledServices, service)), + // Whole-stack startup owns every enabled service, including + // lazy transitive dependencies of eager services (for + // example Studio -> pgmeta). Explicit startService calls + // keep their narrower activation allowlist below. + new Set(enabledServices), ); readiness.push(waitForTargets(started)); } yield* Effect.all(readiness, { concurrency: "unbounded", discard: true }).pipe( (effect) => withReadinessPolicy(effect, "stack"), ); + yield* syncRuntimeProjectedStates(runtime); } else { + yield* prepareServices(enabledServices); + yield* requireMutable("start"); + serviceStartupBegan = true; yield* runtime.orchestrator.start(serviceStartOptions); yield* runtime.orchestrator .waitAllReady() .pipe((effect) => withReadinessPolicy(effect, "stack")); yield* syncRuntimeProjectedStates(runtime); } + yield* requireMutable("start"); yield* Ref.set(phaseRef, "running"); }).pipe( Effect.onError(() => Ref.set(phaseRef, "stopped")), withLifecycleLock, + cleanupOnReadinessFailure, Effect.onError(() => (serviceStartupBegan ? disposeOnce() : Effect.void)), ); }, @@ -772,9 +970,13 @@ export const localStackLayer = ( dispose: disposeOnce, startService: (name) => Effect.gen(function* () { + yield* requireMutable(`start service ${name}`); + yield* requireRunningPhase; + const service = yield* requireKnownServiceName(name); + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireMutable(`start service ${name}`); - const service = yield* requireKnownServiceName(name); + yield* requireRunningPhase; return yield* beginStartTargets( service, new Set(lifecycleTargetsForService(enabledServices, service)), @@ -785,6 +987,7 @@ export const localStackLayer = ( stopService: (name) => Effect.gen(function* () { yield* requireMutable(`stop service ${name}`); + yield* requireRunningPhase; const service = yield* requireKnownServiceName(name); const runtime = yield* ensureRuntime; for (const target of lifecycleTargetsForService( @@ -799,9 +1002,13 @@ export const localStackLayer = ( }).pipe(withLifecycleLock), restartService: (name) => Effect.gen(function* () { + yield* requireMutable(`restart service ${name}`); + yield* requireRunningPhase; + const service = yield* requireKnownServiceName(name); + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireMutable(`restart service ${name}`); - const service = yield* requireKnownServiceName(name); + yield* requireRunningPhase; const runtime = yield* ensureRuntime; yield* runtime.orchestrator.restartService(service, serviceStartOptions); return { runtime, targets: [service] }; @@ -810,14 +1017,19 @@ export const localStackLayer = ( }).pipe(cleanupOnReadinessFailure), reloadFunctions: (opts) => Effect.gen(function* () { + yield* requireMutable("reload functions"); + yield* requireRunningPhase; + yield* requireKnownService("edge-runtime"); + const requestedBundle = + opts?.functions === undefined + ? undefined + : yield* decodeFunctionsBundle(opts.functions); + yield* prepareServices(["edge-runtime"]); const started = yield* Effect.gen(function* () { yield* requireMutable("reload functions"); - yield* requireKnownService("edge-runtime"); + yield* requireRunningPhase; const currentBundle = yield* Ref.get(functionsBundleRef); - const nextBundle = - opts?.functions === undefined - ? currentBundle - : yield* decodeFunctionsBundle(opts.functions); + const nextBundle = requestedBundle ?? currentBundle; yield* configureFunctions(config, nextBundle); yield* Ref.set(functionsBundleRef, nextBundle); const runtime = yield* ensureRuntime; @@ -834,18 +1046,31 @@ export const localStackLayer = ( }).pipe(cleanupOnReadinessFailure), reloadEdgeRuntime: (opts) => Effect.gen(function* () { + yield* requireMutable("reload Edge Runtime"); + yield* requireRunningPhase; + yield* requireKnownService("edge-runtime"); + if (opts.edgeRuntime.enabled === false) { + return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + } + const requestedBundle = + opts.functions === undefined + ? undefined + : yield* decodeFunctionsBundle(opts.functions); + yield* prepareServices(["edge-runtime"]); const started = yield* Effect.gen(function* () { yield* requireMutable("reload Edge Runtime"); - yield* requireKnownService("edge-runtime"); + yield* requireRunningPhase; const nextConfig = yield* configWithEdgeRuntimeOptions(opts); const currentBundle = yield* Ref.get(functionsBundleRef); - const nextBundle = - opts.functions === undefined - ? currentBundle - : yield* decodeFunctionsBundle(opts.functions); - const prepared = yield* ensurePrepared; + const nextBundle = requestedBundle ?? currentBundle; + const prepared = yield* ensurePlanned; const runtime = yield* ensureRuntime; - const buildResult = yield* builder.build(nextConfig, prepared); + const buildResult = yield* builder + .build(nextConfig, prepared) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); const edgeRuntimeDef = buildResult.graph.startOrder.find( (def) => def.name === "edge-runtime", ); diff --git a/packages/stack/src/Platform.ts b/packages/stack/src/Platform.ts index 5ed0e87e5e..cf819bf33c 100644 --- a/packages/stack/src/Platform.ts +++ b/packages/stack/src/Platform.ts @@ -5,40 +5,22 @@ export interface PlatformInfo { readonly arch: string; } +/** Native slim-service release targets. The release set intentionally has no + * windows or x64 macOS artifacts. */ +export type NativeTarget = "darwin-arm64" | "linux-amd64" | "linux-arm64"; + +export const nativeTargetForPlatform = (platform: PlatformInfo): NativeTarget | undefined => { + if (platform.os === "darwin" && platform.arch === "arm64") return "darwin-arm64"; + if (platform.os === "linux" && platform.arch === "x64") return "linux-amd64"; + if (platform.os === "linux" && platform.arch === "arm64") return "linux-arm64"; + return undefined; +}; + export const detectPlatform: Effect.Effect = Effect.sync(() => ({ os: process.platform, arch: process.arch, })); -export const postgresAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "darwin-arm64"; - if (p.os === "linux" && p.arch === "x64") return "linux-x64"; - if (p.os === "linux" && p.arch === "arm64") return "linux-arm64"; - return null; -}; - -export const postgrestAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "macos-aarch64"; - if (p.os === "linux" && p.arch === "x64") return "linux-static-x86-64"; - if (p.os === "linux" && p.arch === "arm64") return "ubuntu-aarch64"; - if (p.os === "win32" && p.arch === "x64") return "windows-x86-64"; - return null; -}; - -export const authAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "darwin-arm64"; - if (p.os === "linux" && p.arch === "x64") return "x86"; - if (p.os === "linux" && p.arch === "arm64") return "arm64"; - return null; -}; - -export const edgeRuntimeAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "aarch64-darwin"; - if (p.os === "linux" && p.arch === "x64") return "x86_64-linux"; - if (p.os === "linux" && p.arch === "arm64") return "aarch64-linux"; - return null; -}; - /** Host address that Docker containers should use to reach services on the host machine. */ export const dockerHostAddress = (_os: string): string => "host.docker.internal"; diff --git a/packages/stack/src/Platform.unit.test.ts b/packages/stack/src/Platform.unit.test.ts index 07a10d2049..67a8095557 100644 --- a/packages/stack/src/Platform.unit.test.ts +++ b/packages/stack/src/Platform.unit.test.ts @@ -4,10 +4,7 @@ import { detectPlatform, dockerHostAddress, dockerNetworkArgs, - postgresAssetName, - postgrestAssetName, - authAssetName, - edgeRuntimeAssetName, + nativeTargetForPlatform, } from "./Platform.ts"; describe("detectPlatform", () => { @@ -22,79 +19,21 @@ describe("detectPlatform", () => { ); }); -describe("postgresAssetName", () => { +describe("nativeTargetForPlatform", () => { it("maps darwin-arm64", () => { - expect(postgresAssetName({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); + expect(nativeTargetForPlatform({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); }); it("maps linux-x64", () => { - expect(postgresAssetName({ os: "linux", arch: "x64" })).toBe("linux-x64"); + expect(nativeTargetForPlatform({ os: "linux", arch: "x64" })).toBe("linux-amd64"); }); it("maps linux-arm64", () => { - expect(postgresAssetName({ os: "linux", arch: "arm64" })).toBe("linux-arm64"); + expect(nativeTargetForPlatform({ os: "linux", arch: "arm64" })).toBe("linux-arm64"); }); it("returns null for unsupported", () => { - expect(postgresAssetName({ os: "win32", arch: "x64" })).toBeNull(); - }); -}); - -describe("postgrestAssetName", () => { - it("maps darwin-arm64 to macos-aarch64", () => { - expect(postgrestAssetName({ os: "darwin", arch: "arm64" })).toBe("macos-aarch64"); - }); - - it("maps linux-x64 to linux-static-x86-64", () => { - expect(postgrestAssetName({ os: "linux", arch: "x64" })).toBe("linux-static-x86-64"); - }); - - it("maps linux-arm64 to ubuntu-aarch64", () => { - expect(postgrestAssetName({ os: "linux", arch: "arm64" })).toBe("ubuntu-aarch64"); - }); - - it("maps win32-x64 to windows-x86-64", () => { - expect(postgrestAssetName({ os: "win32", arch: "x64" })).toBe("windows-x86-64"); - }); - - it("returns null for unsupported", () => { - expect(postgrestAssetName({ os: "win32", arch: "arm64" })).toBeNull(); - }); -}); - -describe("authAssetName", () => { - it("maps darwin-arm64 to darwin-arm64", () => { - expect(authAssetName({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); - }); - - it("maps linux-x64 to x86", () => { - expect(authAssetName({ os: "linux", arch: "x64" })).toBe("x86"); - }); - - it("maps linux-arm64 to arm64", () => { - expect(authAssetName({ os: "linux", arch: "arm64" })).toBe("arm64"); - }); - - it("returns null for unsupported", () => { - expect(authAssetName({ os: "darwin", arch: "x64" })).toBeNull(); - }); -}); - -describe("edgeRuntimeAssetName", () => { - it("maps darwin-arm64 to aarch64-darwin", () => { - expect(edgeRuntimeAssetName({ os: "darwin", arch: "arm64" })).toBe("aarch64-darwin"); - }); - - it("maps linux-x64 to x86_64-linux", () => { - expect(edgeRuntimeAssetName({ os: "linux", arch: "x64" })).toBe("x86_64-linux"); - }); - - it("maps linux-arm64 to aarch64-linux", () => { - expect(edgeRuntimeAssetName({ os: "linux", arch: "arm64" })).toBe("aarch64-linux"); - }); - - it("returns null for unsupported", () => { - expect(edgeRuntimeAssetName({ os: "win32", arch: "x64" })).toBeNull(); + expect(nativeTargetForPlatform({ os: "win32", arch: "x64" })).toBeUndefined(); }); }); diff --git a/packages/stack/src/PortAllocator.integration.test.ts b/packages/stack/src/PortAllocator.integration.test.ts index 7703fe9498..2297e43604 100644 --- a/packages/stack/src/PortAllocator.integration.test.ts +++ b/packages/stack/src/PortAllocator.integration.test.ts @@ -1,159 +1,235 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; import { createServer, type Server } from "node:net"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; -import { allocatePortSet, reservePortSet, type PortReservationRequest } from "./PortAllocator.ts"; - -const listen = (port: number) => - Effect.callback((resume) => { - const server = createServer(); - server.once("error", (error) => resume(Effect.fail(error))); - server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server))); - return Effect.void; - }); - -const close = (server: Server) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; +import { NodeFileSystem } from "@effect/platform-node"; +import { Cause, Effect, Exit, FileSystem } from "effect"; +import { reservePortSet, type PortReservationRequest } from "./PortAllocator.ts"; + +const PORT_LEASE_CHILD = resolve(import.meta.dirname, "../tests/helpers/port-lease-child.ts"); +const STACK_PACKAGE_ROOT = resolve(import.meta.dirname, ".."); + +const INTERRUPTED_ALLOCATION_SCRIPT = ` +import { NodeFileSystem } from "@effect/platform-node"; +import { Effect, Fiber } from "effect"; +import { reservePortSet } from "./src/PortAllocator.ts"; + +const fiber = Effect.runFork( + reservePortSet([{ field: "apiPort", selection: { kind: "automatic" } }]).pipe( + Effect.provide(NodeFileSystem.layer), + ), +); +await Effect.runPromise( + Effect.callback((resume) => queueMicrotask(() => resume(Effect.void))), +); +await Effect.runPromise(Fiber.interrupt(fiber)); +`; + +const interruptedAllocationExits = (runtime: "node" | "bun"): Effect.Effect => + Effect.callback((resume) => { + const args = + runtime === "node" + ? ["--input-type=module", "-e", INTERRUPTED_ALLOCATION_SCRIPT] + : ["-e", INTERRUPTED_ALLOCATION_SCRIPT]; + const child = spawn(runtime, args, { + cwd: STACK_PACKAGE_ROOT, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + let settled = false; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const finish = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resume(effect); + }; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + finish( + Effect.fail( + new Error(`${runtime} retained a listener after interrupted allocation: ${stderr}`), + ), + ); + }, 5_000); + child.once("error", (error) => finish(Effect.fail(error))); + child.once("close", (code, signal) => + finish( + code === 0 + ? Effect.void + : Effect.fail( + new Error(`${runtime} allocator probe exited with ${code ?? signal}: ${stderr}`), + ), + ), + ); + return Effect.sync(() => { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }); }); -const occupyFreePort = () => - Effect.acquireRelease( - Effect.map(listen(0), (server) => { - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Expected TCP server address"); - } - return { port: address.port, server }; - }), - ({ server }) => close(server), - ); - const automatic = (field: PortReservationRequest["field"]): PortReservationRequest => ({ field, selection: { kind: "automatic" }, }); -describe("selected-field port allocation", () => { - it("holds only requested fields and can release and re-reserve them", async () => { - const lease = await Effect.runPromise( - reservePortSet([automatic("apiPort"), automatic("dbPort")]), - ); - - try { - expect(lease.ports.apiPort).toBeGreaterThan(0); - expect(lease.ports.dbPort).toBeGreaterThan(0); - expect("authPort" in lease.ports).toBe(false); - - const unavailable = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]).pipe(Effect.exit), - ); - expect(unavailable._tag).toBe("Failure"); - - await Effect.runPromise(lease.release(["apiPort"])); - const rebound = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]), - ); - expect(rebound.apiPort).toBe(lease.ports.apiPort); +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer))); - await Effect.runPromise(lease.reserve(["apiPort"])); - const unavailableAgain = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]).pipe(Effect.exit), - ); - expect(unavailableAgain._tag).toBe("Failure"); +const occupyFreePort = () => + Effect.acquireRelease( + Effect.callback((resume) => { + const server = createServer(); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + return Effect.sync(() => server.close()); + }).pipe( + Effect.map((server) => { + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Expected address"); + return { port: address.port, server }; + }), + ), + ({ server }) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); - await Effect.runPromise(lease.releaseAll); - const reboundBoth = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - { field: "dbPort", selection: { kind: "exact", port: lease.ports.dbPort! } }, - ]), - ); - expect(reboundBoth.apiPort).toBe(lease.ports.apiPort); - expect(reboundBoth.dbPort).toBe(lease.ports.dbPort); - } finally { - await Effect.runPromise(lease.releaseAll); - } +const startChildLease = () => { + const child = spawn("bun", ["run", PORT_LEASE_CHILD], { + stdio: ["pipe", "pipe", "pipe"], }); + const ready = new Promise<{ readonly apiPort: number; readonly dbPort: number }>( + (resolveReady, rejectReady) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + const newline = stdout.indexOf("\n"); + if (newline === -1) return; + try { + resolveReady(JSON.parse(stdout.slice(0, newline))); + } catch (error) { + rejectReady(new Error(`Invalid child response: ${stdout}`, { cause: error })); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", rejectReady); + child.once("close", (code) => { + rejectReady(new Error(`Child exited before readiness (${code}): ${stderr}`)); + }); + }, + ); + return { child, ready }; +}; + +describe("reservePortSet", () => { + it.each(["node", "bun"] as const)( + "closes a pending listener when allocation is interrupted under %s", + async (runtime) => { + await Effect.runPromise(interruptedAllocationExits(runtime)); + }, + 10_000, + ); - it("fails when an exact port is occupied", async () => { - const exit = await Effect.runPromise( + it("fails an occupied exact port with field and port attribution", async () => { + let occupiedPort = 0; + const exit = await run( Effect.scoped( Effect.gen(function* () { const occupied = yield* occupyFreePort(); - return yield* allocatePortSet([ + occupiedPort = occupied.port; + return yield* reservePortSet([ { field: "apiPort", selection: { kind: "exact", port: occupied.port } }, ]).pipe(Effect.exit); }), ), ); - - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("is not available"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toMatchObject({ field: "apiPort", port: occupiedPort }); } }); - it("keeps concurrent selected-field leases disjoint", async () => { - const [first, second] = await Promise.all([ - Effect.runPromise(reservePortSet([automatic("apiPort"), automatic("dbPort")])), - Effect.runPromise(reservePortSet([automatic("apiPort"), automatic("dbPort")])), - ]); + it("reserves multiple automatic fields and re-reserves selected fields", async () => { + const lease = await run(reservePortSet([automatic("apiPort"), automatic("dbPort")])); + try { + expect(lease.ports.apiPort).toBeGreaterThan(0); + expect(lease.ports.dbPort).toBeGreaterThan(0); + await run(lease.release(["dbPort"])); + await run(lease.reserve(["dbPort"])); + } finally { + await run(lease.releaseAll); + } + }); + it("retains claims after TCP release until releaseAll", async () => { + const lease = await run(reservePortSet([automatic("apiPort")])); + const port = lease.ports.apiPort; + if (port === undefined) throw new Error("Expected API port"); try { - const firstPorts = new Set(Object.values(first.ports)); - expect(Object.values(second.ports).every((port) => !firstPorts.has(port))).toBe(true); + await run(lease.release(["apiPort"])); + const blocked = await run( + reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]).pipe( + Effect.exit, + ), + ); + expect(Exit.isFailure(blocked)).toBe(true); } finally { - await Promise.all([ - Effect.runPromise(first.releaseAll), - Effect.runPromise(second.releaseAll), - ]); + await run(lease.releaseAll); } }); - it("releases partial reservations when a selected set fails", async () => { - const firstPort = await Effect.runPromise( - Effect.scoped(Effect.map(occupyFreePort(), (occupied) => occupied.port)), + it("keeps automatic ports disjoint across child processes", async () => { + const first = startChildLease(); + const second = startChildLease(); + try { + const [left, right] = await Promise.all([first.ready, second.ready]); + const ports = [left.apiPort, left.dbPort, right.apiPort, right.dbPort]; + expect(new Set(ports).size).toBe(ports.length); + } finally { + for (const child of [first.child, second.child]) { + if (child.exitCode === null) { + child.stdin.end("release\n"); + await once(child, "close"); + } + } + } + }, 30_000); + + it("recovers a stale claim left by an unclean child exit", async () => { + const child = startChildLease(); + const ports = await child.ready; + child.child.kill("SIGKILL"); + await once(child.child, "close"); + const lease = await run( + reservePortSet([{ field: "apiPort", selection: { kind: "exact", port: ports.apiPort } }]), ); - const failed = await Effect.runPromise( + await run(lease.releaseAll); + }, 30_000); + + it("rolls back earlier fields when a later exact field is unavailable", async () => { + const failed = await run( Effect.scoped( Effect.gen(function* () { const occupied = yield* occupyFreePort(); return yield* reservePortSet([ - { field: "apiPort", selection: { kind: "exact", port: firstPort } }, + automatic("apiPort"), { field: "dbPort", selection: { kind: "exact", port: occupied.port } }, ]).pipe(Effect.exit); }), ), ); - expect(failed._tag).toBe("Failure"); - - const available = await Effect.runPromise( - allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port: firstPort } }]), - ); - expect(available.apiPort).toBe(firstPort); - }); - - it("releases a bound port when interrupted during lease registration", async () => { - const port = await Effect.runPromise( - Effect.scoped(Effect.map(occupyFreePort(), (occupied) => occupied.port)), - ); - const interrupted = await Effect.runPromise( - reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }], { - onBound: () => Effect.interrupt, - }).pipe(Effect.exit), - ); - expect(interrupted._tag).toBe("Failure"); - - const rebound = await Effect.runPromise( - allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]), - ); - expect(rebound.apiPort).toBe(port); + expect(Exit.isFailure(failed)).toBe(true); + const retry = await run(reservePortSet([automatic("apiPort")])); + await run(retry.releaseAll); }); }); diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts index 6eb7b3ff58..38bde6b75e 100644 --- a/packages/stack/src/PortAllocator.ts +++ b/packages/stack/src/PortAllocator.ts @@ -1,5 +1,19 @@ +import { randomUUID } from "node:crypto"; import { createServer, type Server } from "node:net"; -import { Data, Effect, Schema, Semaphore } from "effect"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + Cause, + Data, + Effect, + Exit, + FileSystem, + Option, + Predicate, + Schema, + Semaphore, +} from "effect"; +import { PlatformError } from "effect/PlatformError"; import { PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts"; export class PortAllocationError extends Data.TaggedError("PortAllocationError")<{ @@ -7,15 +21,27 @@ export class PortAllocationError extends Data.TaggedError("PortAllocationError") readonly cause?: unknown; readonly field?: PortField; readonly port?: number; + readonly reason?: "unavailable" | "failed"; }> { override get message(): string { return this.detail; } } +class PortClaimCollisionError extends Data.TaggedError("PortClaimCollisionError")<{ + readonly port: number; +}> {} + +const MAX_CLAIM_ATTEMPTS = 32; + export type PortSelection = | { readonly kind: "exact"; readonly port: number } - | { readonly kind: "automatic"; readonly preferred?: number }; + | { + readonly kind: "automatic"; + readonly preferred?: number; + /** Additional exclusions used by managed allocation (for example control ports). */ + readonly excluded?: ReadonlySet; + }; export interface PortReservationRequest { readonly field: PortField; @@ -26,90 +52,283 @@ export interface PortSelectionOptions { readonly reserved?: ReadonlySet; } -interface PortAllocationOptions extends PortSelectionOptions { - readonly probe?: PortProbe; - /** @internal deterministic interruption hook used by allocator integration tests. */ - readonly onBound?: (field: PortField, bound: BoundPort) => Effect.Effect; +const closeServer = (server: Server): Effect.Effect => + Effect.callback((resume) => { + server.close((cause) => + resume( + cause === undefined || + (typeof cause === "object" && + cause !== null && + "code" in cause && + Reflect.get(cause, "code") === "ERR_SERVER_NOT_RUNNING") + ? Effect.void + : Effect.die(cause), + ), + ); + return Effect.void; + }); + +interface BoundPort { + readonly port: number; + readonly server: Server; +} + +interface PortClaim { + readonly path: string; + readonly port: number; + readonly token: string; } -interface PortProbe { - readonly exact: (port: number) => Effect.Effect; - readonly random: (exclude: ReadonlySet) => Effect.Effect; +interface ClaimRecord { + readonly pid: number; + readonly token: string; } -/** Bind port 0 to get an OS-assigned random port, then close immediately. */ -const probeRandomPort = ( - exclude: ReadonlySet, -): Effect.Effect => - Effect.flatMap( - Effect.callback((resume) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => resume(Effect.succeed(port))); - }); - server.on("error", (cause) => - resume( - Effect.fail(new PortAllocationError({ detail: "Failed to bind random port", cause })), +interface ClaimSnapshot { + readonly contents: string; + readonly record: ClaimRecord | undefined; + readonly info: FileSystem.File.Info; +} + +const claimNamespace = (): string => { + const uid = process.getuid?.(); + if (uid !== undefined) return `uid-${uid}`; + const username = process.env.USER ?? process.env.USERNAME ?? "unknown"; + const safeUsername = username.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; + return `user-${safeUsername}`; +}; + +const CLAIM_ROOT = join(tmpdir(), `supabase-stack-port-claims-${claimNamespace()}`); +const CLAIM_STALE_AFTER_MS = 30_000; + +const claimPath = (port: number, root = CLAIM_ROOT): string => join(root, `port-${port}`); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return typeof cause === "object" && cause !== null && "code" in cause + ? Reflect.get(cause, "code") !== "ESRCH" + : false; + } +}; + +const isNotFound = (error: PlatformError): boolean => Predicate.isTagged(error.reason, "NotFound"); +const isAlreadyExists = (error: PlatformError): boolean => + Predicate.isTagged(error.reason, "AlreadyExists"); + +const parseClaimRecord = (contents: string): ClaimRecord | undefined => { + try { + const value: unknown = JSON.parse(contents); + if (typeof value !== "object" || value === null) return undefined; + const pid = Reflect.get(value, "pid"); + const token = Reflect.get(value, "token"); + return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && typeof token === "string" + ? { pid, token } + : undefined; + } catch { + return undefined; + } +}; + +const readClaimSnapshot = ( + path: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs + .readFileString(path) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (contents === undefined) return undefined; + const info = yield* fs + .stat(path) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), ), ); - return Effect.void; - }), - (port) => (exclude.has(port) ? probeRandomPort(exclude) : Effect.succeed(port)), + if (info === undefined) return undefined; + return { contents, record: parseClaimRecord(contents), info }; + }); + +const claimIsStale = (snapshot: ClaimSnapshot): boolean => { + if (snapshot.info.type !== "File") return false; + if (snapshot.record !== undefined) return !isProcessAlive(snapshot.record.pid); + return ( + Option.isSome(snapshot.info.mtime) && + Date.now() - snapshot.info.mtime.value.getTime() > CLAIM_STALE_AFTER_MS ); +}; -/** Probe the exact port requested by the user. Fail if it is not available. */ -const probeExactPort = (port: number): Effect.Effect => - Effect.callback((resume) => { - const server = createServer(); - server.listen(port, "127.0.0.1", () => { - server.close(() => resume(Effect.succeed(port))); - }); - server.on("error", () => - resume( - Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })), - ), +const inspectClaim = ( + path: string, +): Effect.Effect< + { readonly snapshot: ClaimSnapshot; readonly stale: boolean } | undefined, + PlatformError, + FileSystem.FileSystem +> => + readClaimSnapshot(path).pipe( + Effect.map((snapshot) => + snapshot === undefined ? undefined : { snapshot, stale: claimIsStale(snapshot) }, + ), + ); + +const claimIdentityMatches = (expected: ClaimSnapshot, current: ClaimSnapshot): boolean => { + if (expected.record !== undefined || current.record !== undefined) { + return ( + expected.record !== undefined && + current.record !== undefined && + expected.record.pid === current.record.pid && + expected.record.token === current.record.token ); - return Effect.void; + } + if (expected.contents !== current.contents) return false; + if (Option.isSome(expected.info.ino) && Option.isSome(current.info.ino)) { + return expected.info.ino.value === current.info.ino.value; + } + return false; +}; + +const removeCreatedClaim = ( + path: string, + contents: string, + openedInfo: FileSystem.File.Info | undefined, + opened: boolean, + fs: FileSystem.FileSystem, +): Effect.Effect => + Effect.gen(function* () { + if (!opened) return; + const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined)); + if (current === undefined) return; + if (openedInfo === undefined) { + if (current.contents.length !== 0) return; + } else if (Option.isSome(openedInfo.ino) && Option.isSome(current.info.ino)) { + if (openedInfo.ino.value !== current.info.ino.value) return; + } else { + if (current.contents !== contents) return; + if ( + !Option.isSome(openedInfo.mtime) || + !Option.isSome(current.info.mtime) || + openedInfo.mtime.value.getTime() !== current.info.mtime.value.getTime() + ) { + return; + } + } + yield* fs.remove(path, { force: true }).pipe(Effect.ignore); + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)); + +const readClaimRecord = ( + path: string, +): Effect.Effect => + readClaimSnapshot(path).pipe(Effect.map((snapshot) => snapshot?.record)); + +const removeStaleClaim = ( + path: string, + expected: ClaimSnapshot, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined)); + if (current === undefined || !claimIdentityMatches(expected, current)) return false; + yield* fs + .remove(path, { force: true }) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.void : Effect.fail(error), + ), + ); + return true; }); -const chooseExactPort = ( +const acquirePortClaimInternal = ( port: number, - exclude: ReadonlySet, - probe: PortProbe, -): Effect.Effect => - exclude.has(port) - ? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })) - : probe.exact(port); + root = CLAIM_ROOT, +): Effect.Effect< + PortClaim, + PlatformError | PortAllocationError | PortClaimCollisionError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(root, { recursive: true }); + const path = claimPath(port, root); + const token = randomUUID(); + const contents = JSON.stringify({ pid: process.pid, token }); + + for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { + let openedInfo: FileSystem.File.Info | undefined; + let created = false; + const openedExit = yield* Effect.exit( + Effect.scoped( + fs.open(path, { flag: "wx", mode: 0o600 }).pipe( + Effect.flatMap((handle) => { + created = true; + return handle.stat.pipe( + Effect.tap((info) => Effect.sync(() => (openedInfo = info))), + Effect.andThen(handle.writeAll(new TextEncoder().encode(contents))), + ); + }), + ), + ), + ); + if (Exit.isSuccess(openedExit)) return { path, port, token }; + yield* Effect.uninterruptible(removeCreatedClaim(path, contents, openedInfo, created, fs)); + const failure = Cause.findErrorOption(openedExit.cause); + if (Option.isNone(failure)) return yield* Effect.failCause(openedExit.cause); + if (!isAlreadyExists(failure.value)) { + return yield* Effect.fail(failure.value); + } + const inspection = yield* inspectClaim(path); + if (inspection === undefined) continue; + if (!inspection.stale) { + return yield* new PortClaimCollisionError({ port }); + } + if (!(yield* removeStaleClaim(path, inspection.snapshot))) continue; + } + return yield* new PortAllocationError({ + detail: `Failed to claim port ${port} after ${MAX_CLAIM_ATTEMPTS} attempts`, + port, + reason: "failed", + }); + }); + +const portAllocationFromCause = (port: number, cause: unknown): PortAllocationError => + cause instanceof PortAllocationError + ? cause + : new PortAllocationError({ + detail: `Failed to claim port ${port}`, + cause, + port, + reason: "failed", + }); -const choosePreferredPort = ( +const acquirePortClaim = ( port: number, - exclude: ReadonlySet, - probe: PortProbe, -): Effect.Effect => - exclude.has(port) - ? probe.random(exclude) - : probe.exact(port).pipe(Effect.catchTag("PortAllocationError", () => probe.random(exclude))); - -const defaultPortProbe: PortProbe = { - exact: probeExactPort, - random: probeRandomPort, -}; + root = CLAIM_ROOT, +): Effect.Effect => + acquirePortClaimInternal(port, root).pipe( + Effect.mapError((cause) => + cause instanceof PortClaimCollisionError ? cause : portAllocationFromCause(port, cause), + ), + ); -const closeServer = (server: Server): Effect.Effect => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; +const releasePortClaim = (claim: PortClaim, fs: FileSystem.FileSystem): Effect.Effect => + Effect.gen(function* () { + const record = yield* readClaimRecord(claim.path).pipe( + Effect.orElseSucceed(() => undefined), + Effect.provideService(FileSystem.FileSystem, fs), + ); + if (record?.token !== claim.token || record.pid !== process.pid) return; + yield* fs.remove(claim.path, { force: true }).pipe(Effect.ignore); }); -interface BoundPort { - readonly port: number; - readonly server: Server; -} - const bindPort = (port: number): Effect.Effect => - Effect.callback((resume) => { + Effect.callback((resume, signal) => { const server = createServer((socket) => socket.destroy()); const onError = (cause: unknown) => { resume( @@ -118,33 +337,41 @@ const bindPort = (port: number): Effect.Effect = detail: port === 0 ? "Failed to reserve a random port" : `Port ${port} is not available`, cause, + reason: port === 0 ? "failed" : "unavailable", ...(port === 0 ? {} : { port }), }), ), ); }; server.once("error", onError); - server.listen(port, "127.0.0.1", () => { + server.listen({ port, host: "127.0.0.1", signal }, () => { server.off("error", onError); const address = server.address(); if (address === null || typeof address === "string") { - void Effect.runPromise(closeServer(server)); resume( - Effect.fail( - new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }), + closeServer(server).pipe( + Effect.andThen( + Effect.fail( + new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }), + ), + ), ), ); return; } resume(Effect.succeed({ port: address.port, server })); }); - return closeServer(server); + return Effect.sync(() => server.off("error", onError)).pipe( + Effect.andThen(closeServer(server)), + ); }); export interface PortLease { readonly ports: PortSet; readonly reserve: (fields: ReadonlyArray) => Effect.Effect; + /** Releases TCP reservations while retaining ownership claims for this lease. */ readonly release: (fields: ReadonlyArray) => Effect.Effect; + /** Releases all TCP reservations and ends ownership of every selected port. */ readonly releaseAll: Effect.Effect; } @@ -172,24 +399,81 @@ const releaseReservations = ( reservations: Map, fields: ReadonlyArray, ): Effect.Effect => - Effect.forEach( - uniquePortFields(fields), - (field) => { - const server = reservations.get(field); - if (server === undefined) return Effect.void; - reservations.delete(field); - return closeServer(server); - }, - { discard: true }, + // Cleanup must finish the ownership handoff even if the caller is + // interrupted between releasing individual fields. + Effect.uninterruptible( + Effect.forEach( + uniquePortFields(fields), + (field) => { + const server = reservations.get(field); + if (server === undefined) return Effect.void; + return closeServer(server).pipe( + Effect.tap(() => Effect.sync(() => reservations.delete(field))), + ); + }, + { discard: true }, + ), + ); + +const releaseClaims = ( + claims: Map, + fields: ReadonlyArray, + fs: FileSystem.FileSystem, +): Effect.Effect => + Effect.uninterruptible( + Effect.forEach( + uniquePortFields(fields), + (field) => { + const claim = claims.get(field); + if (claim === undefined) return Effect.void; + return releasePortClaim(claim, fs).pipe( + Effect.tap(() => Effect.sync(() => claims.delete(field))), + ); + }, + { discard: true }, + ), ); +const claimAndBind = ( + field: PortField, + port: number, + claims: Map, + fs: FileSystem.FileSystem, + root: string, +): Effect.Effect => { + const existingClaim = claims.get(field); + return Effect.gen(function* () { + const bound = yield* Effect.interruptible(bindPort(port)); + if (existingClaim !== undefined) { + claims.set(field, existingClaim); + return bound; + } + + const claimExit = yield* Effect.exit( + Effect.interruptible( + acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ), + ); + if (Exit.isFailure(claimExit)) { + yield* closeServer(bound.server); + return yield* Effect.failCause(claimExit.cause); + } + yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); + return bound; + }); +}; + const reserveReservations = ( ports: PortSet, reservations: Map, + claims: Map, fields: ReadonlyArray, + fs: FileSystem.FileSystem, + root: string, ): Effect.Effect => Effect.suspend(() => { const acquired: Array = []; + const acquiredClaims: Array = []; return Effect.forEach( uniquePortFields(fields), (field) => { @@ -203,52 +487,123 @@ const reserveReservations = ( }), ); } - return bindPort(port).pipe( - Effect.mapError((error) => withPortField(field, error)), - Effect.tap(({ server }) => - Effect.sync(() => { - reservations.set(field, server); - acquired.push(field); - }), + const existingClaim = claims.has(field); + return Effect.uninterruptibleMask(() => + claimAndBind(field, port, claims, fs, root).pipe( + Effect.mapError((error) => + error instanceof PortClaimCollisionError + ? new PortAllocationError({ + detail: `Port ${error.port} is not available`, + field, + port: error.port, + reason: "unavailable", + }) + : withPortField(field, error), + ), + Effect.tap(({ server }) => + Effect.sync(() => { + reservations.set(field, server); + acquired.push(field); + if (!existingClaim) acquiredClaims.push(field); + }), + ), ), ); }, { discard: true }, - ).pipe(Effect.onError(() => releaseReservations(reservations, acquired))); + ).pipe( + Effect.onError(() => + Effect.all( + [releaseReservations(reservations, acquired), releaseClaims(claims, acquiredClaims, fs)], + { discard: true }, + ), + ), + ); }); -const makePortLease = (ports: PortSet, reservations: Map): PortLease => { +const makePortLease = ( + ports: PortSet, + reservations: Map, + claims: Map, + fs: FileSystem.FileSystem, + root: string, +): PortLease => { const lock = Semaphore.makeUnsafe(1); return { ports, - reserve: (fields) => lock.withPermit(reserveReservations(ports, reservations, fields)), + reserve: (fields) => + lock.withPermit(reserveReservations(ports, reservations, claims, fields, fs, root)), release: (fields) => lock.withPermit(releaseReservations(reservations, fields)), releaseAll: lock.withPermit( - Effect.suspend(() => releaseReservations(reservations, [...reservations.keys()])), + Effect.suspend(() => + Effect.all( + [ + releaseReservations(reservations, [...reservations.keys()]), + releaseClaims(claims, [...claims.keys()], fs), + ], + { discard: true }, + ), + ), ), }; }; const reserveRandomPort = ( exclude: ReadonlySet, -): Effect.Effect => - Effect.flatMap(bindPort(0), (bound) => - exclude.has(bound.port) - ? closeServer(bound.server).pipe(Effect.andThen(reserveRandomPort(exclude))) - : Effect.succeed(bound), - ); - -const resolveSelection = ( - selection: PortSelection, - exclude: ReadonlySet, - probe: PortProbe, -): Effect.Effect => { - if (selection.kind === "exact") { - return chooseExactPort(selection.port, exclude, probe); + field: PortField, + claims: Map, + fs: FileSystem.FileSystem, + root: string, + attempt = 0, +): Effect.Effect< + BoundPort, + PortAllocationError | PortClaimCollisionError, + FileSystem.FileSystem +> => { + if (attempt >= MAX_CLAIM_ATTEMPTS) { + return Effect.fail( + new PortAllocationError({ + detail: `Failed to reserve a random port after ${MAX_CLAIM_ATTEMPTS} claim collisions`, + reason: "failed", + }), + ); } - return selection.preferred === undefined - ? probe.random(exclude) - : choosePreferredPort(selection.preferred, exclude, probe); + return Effect.gen(function* () { + const bound = yield* Effect.interruptible(bindPort(0)); + if (exclude.has(bound.port)) { + yield* closeServer(bound.server); + return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); + } + + const claimExit = yield* Effect.exit( + Effect.interruptible( + acquirePortClaim(bound.port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ), + ); + if (Exit.isSuccess(claimExit)) { + yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); + return bound; + } + + yield* closeServer(bound.server); + const failure = Cause.findErrorOption(claimExit.cause); + if (Option.isSome(failure) && failure.value instanceof PortClaimCollisionError) { + return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); + } + if (Option.isSome(failure) && failure.value instanceof PlatformError) { + return yield* Effect.fail(portAllocationFromCause(bound.port, failure.value)); + } + if (Option.isSome(failure) && failure.value instanceof PortAllocationError) { + return yield* Effect.fail(failure.value); + } + return yield* Effect.failCause( + Cause.map(claimExit.cause, (error) => + error instanceof PortClaimCollisionError || error instanceof PortAllocationError + ? error + : portAllocationFromCause(bound.port, error), + ), + ); + }); }; const withPortField = (field: PortField, error: PortAllocationError): PortAllocationError => @@ -256,90 +611,144 @@ const withPortField = (field: PortField, error: PortAllocationError): PortAlloca detail: error.detail, cause: error.cause, field, + reason: error.reason, ...(error.port === undefined ? {} : { port: error.port }), }); -export const allocatePortSet = ( - requests: ReadonlyArray, - options: PortAllocationOptions = {}, +const decodePortSet = ( + partial: Partial>, ): Effect.Effect => - Effect.gen(function* () { - const reserved = options.reserved ?? new Set(); - const probe = options.probe ?? defaultPortProbe; - const allocated = new Set(); - const partial: Partial> = {}; - - for (const request of uniqueFields(requests)) { - const exclude = new Set([...reserved, ...allocated]); - const port = yield* resolveSelection(request.selection, exclude, probe).pipe( - Effect.mapError((error) => withPortField(request.field, error)), - ); - allocated.add(port); - partial[request.field] = port; - } - - return Schema.decodeUnknownSync(PortSetSchema)(partial); - }); + Schema.decodeUnknownEffect(PortSetSchema)(partial).pipe( + Effect.mapError( + (cause) => + new PortAllocationError({ + detail: "Allocated ports did not match the port catalog", + cause, + }), + ), + ); export const reservePortSet = ( requests: ReadonlyArray, - options: PortAllocationOptions = {}, -): Effect.Effect => + options: PortSelectionOptions = {}, +): Effect.Effect => Effect.suspend(() => { const reservations = new Map(); - const reserve = Effect.gen(function* () { - const reserved = options.reserved ?? new Set(); - const allocated = new Set(); - const partial: Partial> = {}; - - const bindAndRegister = ( - field: PortField, - acquisition: Effect.Effect, - ) => - Effect.uninterruptibleMask(() => - Effect.gen(function* () { - const result = yield* acquisition.pipe( - Effect.mapError((error) => withPortField(field, error)), - ); - reservations.set(field, result.server); - yield* options.onBound?.(field, result) ?? Effect.void; - return result; - }), - ); + const claims = new Map(); + const root = CLAIM_ROOT; + const reserve = (fs: FileSystem.FileSystem) => + Effect.gen(function* () { + const reserved = options.reserved ?? new Set(); + const allocated = new Set(); + const partial: Partial> = {}; + + const bindAndRegister = ( + field: PortField, + acquisition: Effect.Effect, + ) => + Effect.uninterruptibleMask(() => + Effect.gen(function* () { + const result = yield* acquisition.pipe( + Effect.mapError((error) => + error instanceof PortClaimCollisionError + ? new PortAllocationError({ + detail: `Port ${error.port} is not available`, + field, + port: error.port, + reason: "unavailable", + }) + : withPortField(field, error), + ), + ); + reservations.set(field, result.server); + return result; + }), + ); - for (const request of uniqueFields(requests)) { - const exclude = new Set([...reserved, ...allocated]); - const selection = request.selection; - let bound: BoundPort; - - if (selection.kind === "exact") { - if (exclude.has(selection.port)) { - return yield* new PortAllocationError({ - detail: `Port ${selection.port} is not available`, - field: request.field, - port: selection.port, - }); + for (const request of uniqueFields(requests)) { + const selection = request.selection; + const exclude = new Set([ + ...reserved, + ...allocated, + ...(selection.kind === "automatic" ? (selection.excluded ?? []) : []), + ]); + let bound: BoundPort; + + if (selection.kind === "exact") { + if ( + !Number.isInteger(selection.port) || + selection.port < 1 || + selection.port > 65_535 + ) { + return yield* new PortAllocationError({ + detail: `Invalid exact port ${selection.port}`, + field: request.field, + port: selection.port, + }); + } + if (exclude.has(selection.port)) { + return yield* new PortAllocationError({ + detail: `Port ${selection.port} is not available`, + field: request.field, + port: selection.port, + }); + } + bound = yield* bindAndRegister( + request.field, + claimAndBind(request.field, selection.port, claims, fs, root), + ); + } else if ( + selection.preferred !== undefined && + selection.preferred > 0 && + !exclude.has(selection.preferred) + ) { + const preferred = selection.preferred; + bound = yield* bindAndRegister( + request.field, + claimAndBind(request.field, preferred, claims, fs, root).pipe( + Effect.catchTag("PortClaimCollisionError", () => + reserveRandomPort(exclude, request.field, claims, fs, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + ), + Effect.catchTag("PortAllocationError", (error) => + error.reason === "unavailable" + ? reserveRandomPort(exclude, request.field, claims, fs, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ) + : Effect.fail(error), + ), + ), + ); + } else { + bound = yield* bindAndRegister( + request.field, + reserveRandomPort(exclude, request.field, claims, fs, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + ); } - bound = yield* bindAndRegister(request.field, bindPort(selection.port)); - } else if (selection.preferred !== undefined && !exclude.has(selection.preferred)) { - bound = yield* bindAndRegister( - request.field, - bindPort(selection.preferred).pipe( - Effect.catchTag("PortAllocationError", () => reserveRandomPort(exclude)), - ), - ); - } else { - bound = yield* bindAndRegister(request.field, reserveRandomPort(exclude)); + + allocated.add(bound.port); + partial[request.field] = bound.port; } - allocated.add(bound.port); - partial[request.field] = bound.port; - } + const ports = yield* decodePortSet(partial); + return makePortLease(ports, reservations, claims, fs, root); + }); - return makePortLease(Schema.decodeUnknownSync(PortSetSchema)(partial), reservations); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* reserve(fs).pipe( + Effect.onError(() => + Effect.all( + [ + releaseReservations(reservations, [...reservations.keys()]), + releaseClaims(claims, [...claims.keys()], fs), + ], + { discard: true }, + ), + ), + ); }); - - return reserve.pipe( - Effect.onError(() => releaseReservations(reservations, [...reservations.keys()])), - ); }); diff --git a/packages/stack/src/PortAllocator.unit.test.ts b/packages/stack/src/PortAllocator.unit.test.ts deleted file mode 100644 index ae5cea6753..0000000000 --- a/packages/stack/src/PortAllocator.unit.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Cause, Effect, Exit, Schema } from "effect"; -import { PortSetSchema } from "./effect.ts"; -import { allocatePortSet, PortAllocationError } from "./PortAllocator.ts"; -import { DEFAULT_PORTS, type PortField } from "./PortCatalog.ts"; - -const fakePortProbe = ( - options: { - readonly unavailable?: ReadonlySet; - readonly randomPorts?: readonly number[]; - } = {}, -) => { - const unavailable = options.unavailable ?? new Set(); - const randomPorts = - options.randomPorts ?? Array.from({ length: 100 }, (_, index) => 30001 + index); - let randomIndex = 0; - - return { - exact: (port: number) => - unavailable.has(port) - ? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })) - : Effect.succeed(port), - random: (exclude: ReadonlySet) => - Effect.gen(function* () { - while (randomIndex < randomPorts.length) { - const port = randomPorts[randomIndex]; - randomIndex += 1; - if (port === undefined) { - continue; - } - if (!exclude.has(port) && !unavailable.has(port)) { - return port; - } - } - - return yield* Effect.fail( - new PortAllocationError({ detail: "No fake random ports available" }), - ); - }), - }; -}; - -describe("allocatePortSet", () => { - const requests = (fields: ReadonlyArray) => - fields.map((field) => ({ field, selection: { kind: "automatic" as const } })); - - it("all allocated ports are unique", async () => { - const ports = await Effect.runPromise( - allocatePortSet( - requests(["apiPort", "dbPort", "authPort", "postgrestPort", "postgrestAdminPort"]), - { probe: fakePortProbe() }, - ), - ); - const values = Object.values(ports) as number[]; - const unique = new Set(values); - expect(unique.size).toBe(values.length); - for (const port of values) { - expect(port).toBeGreaterThan(0); - } - }); - - it("exports the partial allocated port-set schema", () => { - expect(Schema.decodeUnknownSync(PortSetSchema)({ apiPort: 54321 })).toEqual({ - apiPort: 54321, - }); - }); - - it("reserved ports are skipped by later allocations", async () => { - const a = await Effect.runPromise( - allocatePortSet(requests(["apiPort", "dbPort"]), { probe: fakePortProbe() }), - ); - const aPorts = new Set(Object.values(a) as number[]); - const b = await Effect.runPromise( - allocatePortSet(requests(["apiPort", "dbPort"]), { - reserved: aPorts, - probe: fakePortProbe(), - }), - ); - const bPorts = Object.values(b) as number[]; - - for (const port of bPorts) { - expect(aPorts.has(port)).toBe(false); - } - }); - - it("identifies the exact field that collides with an earlier request", async () => { - const exit = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "exact", port: 22_001 } }, - { field: "dbPort", selection: { kind: "exact", port: 22_001 } }, - ], - { probe: fakePortProbe() }, - ).pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ - _tag: "PortAllocationError", - field: "dbPort", - port: 22_001, - }); - } - }); - - it("explicit port is respected when available", async () => { - const requestedApiPort = 21001; - const requestedDbPort = 21002; - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "exact", port: requestedApiPort } }, - { field: "dbPort", selection: { kind: "exact", port: requestedDbPort } }, - ], - { probe: fakePortProbe() }, - ), - ); - expect(ports.apiPort).toBe(requestedApiPort); - expect(ports.dbPort).toBe(requestedDbPort); - }); - - it("preferred ports are reused when available", async () => { - const apiPort = 21003; - const dbPort = 21004; - const studioPort = 21005; - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "automatic", preferred: apiPort } }, - { field: "dbPort", selection: { kind: "automatic", preferred: dbPort } }, - { field: "studioPort", selection: { kind: "automatic", preferred: studioPort } }, - ], - { probe: fakePortProbe() }, - ), - ); - - expect(ports.apiPort).toBe(apiPort); - expect(ports.dbPort).toBe(dbPort); - expect(ports.studioPort).toBe(studioPort); - }); - - it("preferred ports fall back to random ports when unavailable", async () => { - const apiPort = 21006; - const dbPort = 21007; - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "automatic", preferred: apiPort } }, - { field: "dbPort", selection: { kind: "automatic", preferred: dbPort } }, - ], - { - probe: fakePortProbe({ - unavailable: new Set([apiPort]), - randomPorts: Array.from({ length: 20 }, (_, index) => 31001 + index), - }), - }, - ), - ); - - expect(ports.apiPort).toBe(31001); - expect(ports.dbPort).toBe(dbPort); - }); - - it("explicit ports cannot override reserved ownership", async () => { - const exit = await Effect.runPromise( - allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port: 22001 } }], { - reserved: new Set([22001]), - }).pipe(Effect.exit), - ); - - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("Port 22001 is not available"); - } - }); - - it("preferred ports skip reserved ownership and use random fallback", async () => { - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "automatic", preferred: 23001 } }, - { field: "dbPort", selection: { kind: "automatic", preferred: DEFAULT_PORTS.dbPort } }, - ], - { - reserved: new Set([23001]), - probe: fakePortProbe(), - }, - ), - ); - - expect(ports.apiPort).not.toBe(23001); - expect(ports.dbPort).toBe(DEFAULT_PORTS.dbPort); - }); -}); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index 548eaf95fe..b7e632cd2c 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -1,10 +1,20 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; -import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime, Result, Stream } from "effect"; +import { + Cause, + Effect, + Exit, + Fiber, + Layer, + ManagedRuntime, + Predicate, + Result, + Stream, +} from "effect"; import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts"; @@ -85,6 +95,7 @@ function mockStack( readonly waitReadyBuildReason?: "invalid_config" | "docker_not_running" | "asset_preparation"; readonly waitReadyTimeoutMs?: number; readonly restartServiceReadyError?: string; + readonly notRunningPhase?: string; } = {}, ) { let stopped = false; @@ -107,54 +118,64 @@ function mockStack( startService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : options.startServiceBuildError !== undefined - ? Effect.fail( - new StackBuildError({ - detail: options.startServiceBuildError, - ...(options.startServiceBuildReason === undefined - ? {} - : { reason: options.startServiceBuildReason }), - }), - ) - : options.startServiceReadyError !== undefined + : options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : options.startServiceBuildError !== undefined ? Effect.fail( - new ServiceReadyError({ - name, - reason: options.startServiceReadyError, + new StackBuildError({ + detail: options.startServiceBuildError, + ...(options.startServiceBuildReason === undefined + ? {} + : { reason: options.startServiceBuildReason }), }), ) - : Effect.sync(() => { - serviceCalls.push(`start:${name}`); - }), + : options.startServiceReadyError !== undefined + ? Effect.fail( + new ServiceReadyError({ + name, + reason: options.startServiceReadyError, + }), + ) + : Effect.sync(() => { + serviceCalls.push(`start:${name}`); + }), stopService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.sync(() => { - serviceCalls.push(`stop:${name}`); - }), + : options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : Effect.sync(() => { + serviceCalls.push(`stop:${name}`); + }), restartService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : options.restartServiceReadyError !== undefined - ? Effect.fail( - new ServiceReadyError({ - name, - reason: options.restartServiceReadyError, + : options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : options.restartServiceReadyError !== undefined + ? Effect.fail( + new ServiceReadyError({ + name, + reason: options.restartServiceReadyError, + }), + ) + : Effect.sync(() => { + serviceCalls.push(`restart:${name}`); }), - ) - : Effect.sync(() => { - serviceCalls.push(`restart:${name}`); - }), reloadFunctions: (config) => - Effect.sync(() => { - functionReloads.push(config ?? {}); - serviceCalls.push("reload-functions"); - }), + options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : Effect.sync(() => { + functionReloads.push(config ?? {}); + serviceCalls.push("reload-functions"); + }), reloadEdgeRuntime: (config) => - Effect.sync(() => { - edgeRuntimeReloads.push(config); - serviceCalls.push("reload-edge-runtime"); - }), + options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : Effect.sync(() => { + edgeRuntimeReloads.push(config); + serviceCalls.push("reload-edge-runtime"); + }), getState: (name: string) => { const match = MOCK_STATES.find((s) => s.name === name); return match ? Effect.succeed(match) : Effect.fail(new ServiceNotFoundError({ name })); @@ -293,7 +314,7 @@ describe("RemoteStack integration", () => { const daemon = await serverRuntime.runPromise(DaemonServer); const addr = daemon.address; - if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; const url = `http://${host}:${addr.port}`; clientRuntime = ManagedRuntime.make(buildClientLayer(url)); @@ -337,7 +358,7 @@ describe("RemoteStack integration", () => { const exit = await clientRuntime.runPromiseExit( Effect.flatMap(Stack, (stack) => stack.getState("unknown")), ); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); }); test("startService records the call", async () => { @@ -351,7 +372,7 @@ describe("RemoteStack integration", () => { const exit = await clientRuntime.runPromiseExit( Effect.flatMap(Stack, (stack) => stack.startService("unknown")), ); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); }); test("waitReady passes one validated finite override through the daemon", async () => { @@ -369,7 +390,7 @@ describe("RemoteStack integration", () => { const error = await clientRuntime.runPromise( Effect.flatMap(Stack, (stack) => stack.waitReady("..")).pipe(Effect.flip), ); - expect(error._tag).toBe("ServiceNotFoundError"); + expect(Predicate.isTagged(error, "ServiceNotFoundError")).toBe(true); expect(mock.serviceCalls).not.toContain("ready:all"); }); @@ -389,15 +410,15 @@ describe("RemoteStack integration", () => { try { const daemon = await failingServer.runPromise(DaemonServer); const addr = daemon.address; - if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); const error = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip), ); - expect(error._tag).toBe("StackReadinessError"); - if (error._tag === "StackReadinessError") { + expect(Predicate.isTagged(error, "StackReadinessError")).toBe(true); + if (Predicate.isTagged(error, "StackReadinessError")) { expect(error.target).toBe("auth"); expect(error.timeoutMs).toBe(75); } @@ -537,31 +558,31 @@ describe("RemoteStack integration", () => { try { const daemon = await failingServer.runPromise(DaemonServer); const addr = daemon.address; - if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); const startError = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip), ); - expect(startError._tag).toBe("StackBuildError"); - if (startError._tag === "StackBuildError") { + expect(Predicate.isTagged(startError, "StackBuildError")).toBe(true); + if (Predicate.isTagged(startError, "StackBuildError")) { expect(startError.reason).toBe("docker_not_running"); } const readyError = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip), ); - expect(readyError._tag).toBe("StackBuildError"); - if (readyError._tag === "StackBuildError") { + expect(Predicate.isTagged(readyError, "StackBuildError")).toBe(true); + if (Predicate.isTagged(readyError, "StackBuildError")) { expect(readyError.reason).toBe("invalid_config"); } const restartError = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.restartService("auth")).pipe(Effect.flip), ); - expect(restartError._tag).toBe("ServiceReadyError"); - if (restartError._tag === "ServiceReadyError") { + expect(Predicate.isTagged(restartError, "ServiceReadyError")).toBe(true); + if (Predicate.isTagged(restartError, "ServiceReadyError")) { expect(restartError.reason).toBe("restart failed readiness"); } } finally { @@ -570,6 +591,39 @@ describe("RemoteStack integration", () => { } }); + test("preserves StackNotRunningError across mutating daemon operations", async () => { + const failingMock = mockStack({ notRunningPhase: "stopped" }); + const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); + let failingClient: ManagedRuntime.ManagedRuntime | undefined; + try { + const daemon = await failingServer.runPromise(DaemonServer); + const addr = daemon.address; + if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); + const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; + failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); + + const operations = [ + (stack: Stack["Service"]) => stack.startService("auth"), + (stack: Stack["Service"]) => stack.stopService("auth"), + (stack: Stack["Service"]) => stack.restartService("auth"), + (stack: Stack["Service"]) => stack.reloadFunctions(), + (stack: Stack["Service"]) => + stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }), + ]; + for (const operation of operations) { + const error = await failingClient.runPromise( + Effect.flatMap(Stack, operation).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(StackNotRunningError); + expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true); + if (Predicate.isTagged(error, "StackNotRunningError")) expect(error.phase).toBe("stopped"); + } + } finally { + await failingClient?.dispose(); + await failingServer.dispose(); + } + }); + test("preserves ServiceReadyError from remote startService", async () => { const failingMock = mockStack({ startServiceReadyError: "start failed readiness" }); const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); @@ -577,15 +631,15 @@ describe("RemoteStack integration", () => { try { const daemon = await failingServer.runPromise(DaemonServer); const addr = daemon.address; - if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); const error = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip), ); - expect(error._tag).toBe("ServiceReadyError"); - if (error._tag === "ServiceReadyError") { + expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true); + if (Predicate.isTagged(error, "ServiceReadyError")) { expect(error.reason).toBe("start failed readiness"); } } finally { @@ -627,8 +681,8 @@ describe("RemoteStack integration", () => { ); expect(error).toBeInstanceOf(StackBuildError); - expect(error._tag).toBe("StackBuildError"); - if (error._tag === "StackBuildError") { + expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); + if (Predicate.isTagged(error, "StackBuildError")) { expect(error.detail).toBe("Invalid Edge Functions reload payload"); } }); @@ -686,7 +740,7 @@ describe("RemoteStack integration", () => { try { const daemon = await freshServer.runPromise(DaemonServer); const addr = daemon.address; - if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; const freshUrl = `http://${host}:${addr.port}`; diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index e133f53823..7ee88e81e1 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -1,9 +1,9 @@ import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; -import { Effect, Layer, Schema, Stream } from "effect"; +import { Effect, Layer, Predicate, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { DaemonErrorResponseSchema } from "./DaemonProtocol.ts"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import { Stack, StackInfoSchema } from "./Stack.ts"; import { inheritReadyOptions } from "./StackConfig.ts"; import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; @@ -39,8 +39,6 @@ const StatusResponseSchema = Schema.Struct({ const StatusServiceEventSchema = Schema.fromJsonString(StatusServiceSchema); const LogEntryEventSchema = Schema.fromJsonString(LogEntrySchema); -const decodeStatusServiceEvent = Schema.decodeUnknownSync(StatusServiceEventSchema); -const decodeLogEntryEvent = Schema.decodeUnknownSync(LogEntryEventSchema); // --------------------------------------------------------------------------- // Helpers @@ -57,28 +55,62 @@ const publicServicePath = (name: string): Effect.Effect => + Schema.decodeUnknownEffect(StatusServiceEventSchema)(data).pipe( + Effect.map(toServiceState), + Effect.mapError( + (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }), + ), + ); + +const decodeLogEntryEvent = ( + endpoint: ControlEndpoint, + path: string, + data: string, +): Effect.Effect => + Schema.decodeUnknownEffect(LogEntryEventSchema)(data).pipe( + Effect.mapError( + (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }), + ), + ); + +function makeRequest( + endpoint: ControlEndpoint, + path: string, + init?: RequestInit, +): Effect.Effect { const url = `http://localhost${path}`; const method = init?.method?.toUpperCase() ?? "GET"; switch (method) { case "GET": - return HttpClientRequest.get(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.get(url, { headers: requestHeaders(init) })); case "POST": - return HttpClientRequest.post(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.post(url, { headers: requestHeaders(init) })); case "PUT": - return HttpClientRequest.put(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.put(url, { headers: requestHeaders(init) })); case "PATCH": - return HttpClientRequest.patch(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.patch(url, { headers: requestHeaders(init) })); case "DELETE": - return HttpClientRequest.delete(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.delete(url, { headers: requestHeaders(init) })); case "HEAD": - return HttpClientRequest.head(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.head(url, { headers: requestHeaders(init) })); case "OPTIONS": - return HttpClientRequest.options(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.options(url, { headers: requestHeaders(init) })); case "TRACE": - return HttpClientRequest.trace(url, { headers: requestHeaders(init) }); + return Effect.succeed(HttpClientRequest.trace(url, { headers: requestHeaders(init) })); default: - throw new Error(`Unsupported HTTP method: ${method}`); + return Effect.fail( + new HttpTransportClientError({ + endpoint, + path, + cause: `Unsupported HTTP method: ${method}`, + reason: "protocol", + }), + ); } } @@ -88,10 +120,11 @@ function httpFetch(endpoint: ControlEndpoint, path: string, init?: RequestInit) } function httpResponse(endpoint: ControlEndpoint, path: string, init?: RequestInit) { - const request = makeRequest(path, init); - return Effect.map(httpFetch(endpoint, path, init), (response) => - HttpClientResponse.fromWeb(request, response), - ); + return Effect.gen(function* () { + const request = yield* makeRequest(endpoint, path, init); + const response = yield* httpFetch(endpoint, path, init); + return HttpClientResponse.fromWeb(request, response); + }); } /** Preserve daemon RPC identity when an HTTP status or body cannot be decoded. */ @@ -138,7 +171,11 @@ const failDaemonResponse = ( fallbackName: string, ): Effect.Effect< never, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError > => Effect.gen(function* () { const body = yield* dieOnBodyDecodeError( @@ -166,6 +203,8 @@ const failDaemonResponse = ( timeoutMs: body.timeoutMs ?? 0, detail: body.error, }); + case "STACK_NOT_RUNNING": + return yield* new StackNotRunningError({ phase: body.phase ?? "unknown" }); } }); @@ -177,6 +216,25 @@ const expectDaemonOk = ( ): Effect.Effect< void, ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError +> => + response.status >= 200 && response.status < 300 + ? Effect.void + : failDaemonResponse(endpoint, path, response, fallbackName).pipe( + Effect.catchTag("StackNotRunningError", (error) => Effect.die(error)), + ); + +const expectMutatingDaemonOk = ( + endpoint: ControlEndpoint, + path: string, + response: HttpClientResponse.HttpClientResponse, + fallbackName: string, +): Effect.Effect< + void, + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError > => response.status >= 200 && response.status < 300 ? Effect.void @@ -234,7 +292,11 @@ function encodeSearchParams( } /** Convert a ReadableStream SSE body into an Effect Stream of parsed events. */ -function sseStream(endpoint: ControlEndpoint, path: string, parse: (data: string) => A) { +function sseStream( + endpoint: ControlEndpoint, + path: string, + parse: (data: string) => Effect.Effect, +) { return Stream.unwrap( Effect.gen(function* () { const controller = new AbortController(); @@ -258,10 +320,10 @@ function sseStream(endpoint: ControlEndpoint, path: string, parse: (data: str } // State shared across chunks — parser is stateful, accumulates partial events - const collected: A[] = []; + const collected: string[] = []; const parser = Sse.makeParser((event) => { - if (event._tag === "Event") { - collected.push(parse(event.data)); + if (Predicate.isTagged(event, "Event")) { + collected.push(event.data); } }); @@ -271,15 +333,11 @@ function sseStream(endpoint: ControlEndpoint, path: string, parse: (data: str new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), }).pipe( Stream.mapEffect((chunk: Uint8Array) => - Effect.try({ - try: () => { - collected.length = 0; - parser.feed(new TextDecoder().decode(chunk, { stream: true })); - return Array.from(collected); - }, - catch: (cause) => - new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }), - }), + Effect.sync(() => { + collected.length = 0; + parser.feed(new TextDecoder().decode(chunk, { stream: true })); + return Array.from(collected); + }).pipe(Effect.flatMap((events) => Effect.forEach(events, parse))), ), Stream.flatMap(Stream.fromIterable), Stream.ensuring(Effect.sync(() => controller.abort())), @@ -334,6 +392,9 @@ export const RemoteStack = { Stream.provide(httpTransportClientLayer), Stream.catchTag("HttpTransportClientError", (error) => Stream.die(error)), ); + const withLifecycleRequest = ( + request: (signal: AbortSignal) => Effect.Effect, + ) => withHttpTransportClient(withAbortSignal(request)); return { getInfo: () => @@ -342,10 +403,10 @@ export const RemoteStack = { ), start: () => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/start"; - const response = yield* httpResponse(endpoint, path, { method: "POST" }); + const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); yield* expectDaemonOk(endpoint, path, response, "stack").pipe( Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), ); @@ -353,10 +414,10 @@ export const RemoteStack = { ), stop: () => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/stop"; - const response = yield* httpResponse(endpoint, path, { method: "POST" }); + const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); yield* dieOnNonOkStatus( endpoint, path, @@ -366,10 +427,10 @@ export const RemoteStack = { ), dispose: () => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/stop"; - const response = yield* httpResponse(endpoint, path, { method: "POST" }); + const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); yield* dieOnNonOkStatus( endpoint, path, @@ -379,26 +440,28 @@ export const RemoteStack = { ), startService: (name: string) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); const path = `/services/${servicePath}/start`; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, }); - yield* expectDaemonOk(endpoint, path, response, name); + yield* expectMutatingDaemonOk(endpoint, path, response, name); }), ), stopService: (name: string) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); const path = `/services/${servicePath}/stop`; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, }); - yield* expectDaemonOk(endpoint, path, response, name).pipe( + yield* expectMutatingDaemonOk(endpoint, path, response, name).pipe( Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)), Effect.catchTag("StackReadinessError", (error) => Effect.die(error)), ); @@ -406,40 +469,43 @@ export const RemoteStack = { ), restartService: (name: string) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); const path = `/services/${servicePath}/restart`; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, }); - yield* expectDaemonOk(endpoint, path, response, name); + yield* expectMutatingDaemonOk(endpoint, path, response, name); }), ), reloadFunctions: (opts) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/functions/reload"; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, headers: { "content-type": "application/json" }, body: JSON.stringify(opts ?? {}), }); - yield* expectDaemonOk(endpoint, path, response, "edge-runtime"); + yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); }), ), reloadEdgeRuntime: (opts) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/edge-runtime/reload"; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, headers: { "content-type": "application/json" }, body: JSON.stringify(opts), }); - yield* expectDaemonOk(endpoint, path, response, "edge-runtime"); + yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); }), ), @@ -471,20 +537,18 @@ export const RemoteStack = { return yield* new ServiceNotFoundError({ name }); } return withHttpTransportClientStream( - sseStream(endpoint, "/status/stream", (data) => { - const raw = decodeStatusServiceEvent(data); - return toServiceState(raw); - }).pipe(Stream.filter((s) => s.name === name)), + sseStream(endpoint, "/status/stream", (data) => + decodeStatusServiceEvent(endpoint, "/status/stream", data), + ).pipe(Stream.filter((s) => s.name === name)), ); }), ), allStateChanges: () => withHttpTransportClientStream( - sseStream(endpoint, "/status/stream", (data) => { - const raw = decodeStatusServiceEvent(data); - return toServiceState(raw); - }), + sseStream(endpoint, "/status/stream", (data) => + decodeStatusServiceEvent(endpoint, "/status/stream", data), + ), ), waitReady: (name, opts) => @@ -525,14 +589,16 @@ export const RemoteStack = { subscribeLogs: (name: string) => withHttpTransportClientStream( sseStream(endpoint, `/logs/${encodeURIComponent(name)}`, (data) => - decodeLogEntryEvent(data), + decodeLogEntryEvent(endpoint, `/logs/${encodeURIComponent(name)}`, data), ), ), subscribeAllLogs: (services) => { const query = encodeSearchParams({ service: services }); return withHttpTransportClientStream( - sseStream(endpoint, `/logs${query}`, (data) => decodeLogEntryEvent(data)), + sseStream(endpoint, `/logs${query}`, (data) => + decodeLogEntryEvent(endpoint, `/logs${query}`, data), + ), ); }, diff --git a/packages/stack/src/ServiceActivation.ts b/packages/stack/src/ServiceActivation.ts index f61281abf4..78b45c0a2f 100644 --- a/packages/stack/src/ServiceActivation.ts +++ b/packages/stack/src/ServiceActivation.ts @@ -6,9 +6,12 @@ import { stackServiceStartupBudgetSeconds } from "./services/health-budgets.ts"; import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; import type { ReadinessPolicy } from "./StackConfig.ts"; +import type { ServicePolicyManifest } from "./StackConfig.ts"; -export const eagerServices = (enabled: ReadonlyArray): ReadonlyArray => - enabled.filter((service) => serviceMetadata(service).activation.startup === "eager"); +export const eagerServices = ( + enabled: ReadonlyArray, + policies: ServicePolicyManifest, +): ReadonlyArray => enabled.filter((service) => policies[service] === "eager"); export const activationTargetsForService = ( enabledServices: ReadonlyArray, diff --git a/packages/stack/src/ServiceActivation.unit.test.ts b/packages/stack/src/ServiceActivation.unit.test.ts index 0f28d4d6f2..531e155d26 100644 --- a/packages/stack/src/ServiceActivation.unit.test.ts +++ b/packages/stack/src/ServiceActivation.unit.test.ts @@ -7,6 +7,7 @@ import { lifecycleTargetsForService, } from "./ServiceActivation.ts"; import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { DEFAULT_SERVICE_POLICIES } from "./ServiceCatalog.ts"; describe("service activation", () => { it("defines an access policy for every stack service", () => { @@ -14,7 +15,7 @@ describe("service activation", () => { }); it("starts direct endpoints eagerly", () => { - expect(eagerServices(SERVICE_NAMES)).toEqual([ + expect(eagerServices(SERVICE_NAMES, DEFAULT_SERVICE_POLICIES)).toEqual([ "postgres", "realtime", "mailpit", diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index fb8c5ea740..fb3a3a5408 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -1,25 +1,24 @@ import { Record } from "effect"; -import { - authAssetName, - edgeRuntimeAssetName, - postgresAssetName, - postgrestAssetName, - type PlatformInfo, -} from "./Platform.ts"; +import { nativeTargetForPlatform, type NativeTarget, type PlatformInfo } from "./Platform.ts"; import type { PortField } from "./PortCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; -type ArtifactOwnership = "supabase" | "upstream"; type ServiceRuntimeSupport = "native-preferred" | "docker-only"; -export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip"; +type ArchiveFormat = "tar.zst"; +export type ServicePreparationPolicy = "off" | "lazy" | "eager"; export interface NativeReleaseArtifact { + readonly service: ServiceName; + readonly version: string; readonly provider: string; readonly assetName: string; + readonly releaseTag: string; + readonly target: NativeTarget; readonly archive: ArchiveFormat; readonly downloadUrl: string; - readonly checksumUrl: string | null; - readonly stripComponents: boolean; + readonly manifestUrl: string; + readonly checksumUrl: string; + readonly requiredRuntimePaths: ReadonlyArray; } interface NativeReleaseSource { @@ -28,7 +27,6 @@ interface NativeReleaseSource { } interface DockerImageSource { - readonly ownership: ArtifactOwnership; readonly repository: string; readonly tagPrefix?: string; } @@ -39,14 +37,20 @@ interface ServiceArtifactDefinition { } interface ServiceActivationPolicy { - /** Whether the public service must already be running when lazy startup completes. */ - readonly startup: "eager" | "lazy"; /** Other public services required when this service is activated. */ readonly activates: ReadonlyArray; /** Private companions whose lifecycle is exclusively owned by this service. */ readonly owns: ReadonlyArray; } +export interface ServicePreparationMetadata { + /** Policies supported by the service's runtime/resource implementation. */ + readonly supported: ReadonlyArray>; + readonly default: Exclude; + /** Services whose resources must be materialized before this service can start. */ + readonly dependencies: ReadonlyArray; +} + type ServiceConfigKey = | "postgres" | "postgrest" @@ -69,36 +73,50 @@ export interface ServiceCatalogEntry { readonly runtimeSupport: ServiceRuntimeSupport; readonly artifact: ServiceArtifactDefinition; readonly activation: ServiceActivationPolicy; + readonly preparation: ServicePreparationMetadata; readonly portFields: ReadonlyArray; } -const SUPABASE_ECR_REGISTRY = "public.ecr.aws/supabase"; -const SUPABASE_DOCKER_HUB_REGISTRY = "supabase"; -const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase"; +const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase/cli"; +const SLIM_RELEASE_BASE = "https://github.com/supabase/slim-services/releases/download"; const nativeRelease = ( - provider: string, - assetName: string | null, - archive: ArchiveFormat, - downloadUrl: string, - options?: { - readonly checksumUrl?: string; - readonly stripComponents?: boolean; + service: ServiceName, + version: string, + platform: PlatformInfo, + options: { + readonly requiredRuntimePaths: ReadonlyArray; }, -): NativeReleaseArtifact | undefined => - assetName === null - ? undefined - : { - provider, - assetName, - archive, - downloadUrl, - checksumUrl: options?.checksumUrl ?? null, - stripComponents: options?.stripComponents ?? false, - }; +): NativeReleaseArtifact | undefined => { + const target = nativeTargetForPlatform(platform); + if (target === undefined) return undefined; + const releaseTag = `${service}-${version}`; + const base = `${SLIM_RELEASE_BASE}/${releaseTag}`; + const assetName = `${releaseTag}-${target}`; + return { + service, + version, + provider: "github.com/supabase/slim-services", + assetName, + releaseTag, + target, + archive: "tar.zst", + downloadUrl: `${base}/${assetName}.tar.zst`, + manifestUrl: `${base}/${assetName}.manifest.json`, + checksumUrl: `${base}/SHA256SUMS`, + requiredRuntimePaths: options.requiredRuntimePaths, + }; +}; -const authReleaseTag = (version: string): string => - version.includes("-rc.") ? `rc${version}` : `v${version}`; +const preparation = ( + supported: ReadonlyArray>, + defaultPolicy: Exclude, + dependencies: ReadonlyArray = [], +): ServicePreparationMetadata => ({ + supported, + default: defaultPolicy, + dependencies, +}); /** * Exhaustive static identity and capability metadata for public stack services. @@ -111,109 +129,99 @@ export const SERVICE_CATALOG = { defaultVersion: "17.6.1.165", runtimeSupport: "native-preferred", artifact: { - docker: { ownership: "supabase", repository: "postgres" }, + docker: { repository: "postgres" }, native: { - provider: "github.com/supabase/postgres", - resolve: (version, platform) => { - const assetName = postgresAssetName(platform); - const cliVersion = `${version}-cli`; - const url = `https://github.com/supabase/postgres/releases/download/v${cliVersion}/supabase-postgres-v${cliVersion}-${assetName}.tar.gz`; - return nativeRelease("github.com/supabase/postgres", assetName, "tar.gz", url, { - checksumUrl: `${url}.sha256`, - stripComponents: true, - }); - }, + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease("postgres", version, platform, { + requiredRuntimePaths: [ + "bin/postgres", + "bin/pg_isready", + "bin/psql", + "share/supabase-cli/bin/supabase-postgres-init.sh", + "share/supabase-cli/config/pgsodium_getkey.sh", + "share/supabase-cli/migrations", + "lib", + ], + }), }, }, - activation: { startup: "eager", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager"), portFields: ["dbPort"], }, postgrest: { name: "postgrest", configKey: "postgrest", - defaultVersion: "16.1", + defaultVersion: "v16.1", runtimeSupport: "native-preferred", artifact: { - docker: { ownership: "supabase", repository: "postgrest", tagPrefix: "v" }, + docker: { repository: "postgrest" }, native: { - provider: "github.com/PostgREST/postgrest", - resolve: (version, platform) => { - const assetName = postgrestAssetName(platform); - const archive = assetName?.startsWith("windows") === true ? "zip" : "tar.xz"; - return nativeRelease( - "github.com/PostgREST/postgrest", - assetName, - archive, - `https://github.com/PostgREST/postgrest/releases/download/v${version}/postgrest-v${version}-${assetName}.${archive}`, - ); - }, + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease("postgrest", version, platform, { + requiredRuntimePaths: ["bin/postgrest"], + }), }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["postgrestPort", "postgrestAdminPort"], }, auth: { name: "auth", configKey: "auth", - defaultVersion: "2.196.0", + defaultVersion: "v2.196.0", runtimeSupport: "native-preferred", artifact: { - docker: { ownership: "supabase", repository: "gotrue", tagPrefix: "v" }, + docker: { repository: "auth" }, native: { - provider: "github.com/supabase/auth", - resolve: (version, platform) => { - const assetName = authAssetName(platform); - return nativeRelease( - "github.com/supabase/auth", - assetName, - "tar.gz", - `https://github.com/supabase/auth/releases/download/${authReleaseTag(version)}/auth-v${version}-${assetName}.tar.gz`, - ); - }, + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease("auth", version, platform, { + requiredRuntimePaths: ["bin/auth"], + }), }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["authPort"], }, "edge-runtime": { name: "edge-runtime", configKey: "edgeRuntime", - defaultVersion: "1.74.3", + defaultVersion: "v1.74.3", runtimeSupport: "docker-only", artifact: { - docker: { ownership: "supabase", repository: "edge-runtime", tagPrefix: "v" }, - native: { - provider: "github.com/supabase/edge-runtime", - resolve: (version, platform) => { - const assetName = edgeRuntimeAssetName(platform); - return nativeRelease( - "github.com/supabase/edge-runtime", - assetName, - "tar.gz", - `https://github.com/supabase/edge-runtime/releases/download/v${version}/edge-runtime-v${version}-${assetName}.tar.gz`, - ); - }, - }, + docker: { repository: "edge-runtime" }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["edgeRuntimePort", "edgeRuntimeInspectorPort"], }, realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "2.129.3", + defaultVersion: "v2.129.3", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" } }, - activation: { startup: "eager", activates: [], owns: [] }, + artifact: { + docker: { repository: "realtime" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager", ["postgres"]), portFields: ["realtimePort"], }, storage: { name: "storage", configKey: "storage", - defaultVersion: "1.70.3", + defaultVersion: "v1.70.3", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" } }, - activation: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] }, + artifact: { + docker: { repository: "storage" }, + }, + activation: { activates: ["imgproxy"], owns: ["imgproxy"] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "imgproxy"]), portFields: ["storagePort"], }, imgproxy: { @@ -221,8 +229,11 @@ export const SERVICE_CATALOG = { configKey: "imgproxy", defaultVersion: "v3.8.0", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "darthsim/imgproxy" } }, - activation: { startup: "lazy", activates: [], owns: [] }, + artifact: { + docker: { repository: "imgproxy" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["storage"]), portFields: ["imgproxyPort"], }, mailpit: { @@ -230,8 +241,11 @@ export const SERVICE_CATALOG = { configKey: "mailpit", defaultVersion: "v1.30.2", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "axllent/mailpit" } }, - activation: { startup: "eager", activates: [], owns: [] }, + artifact: { + docker: { repository: "mailpit" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager"), portFields: ["mailpitPort", "mailpitSmtpPort", "mailpitPop3Port"], }, pgmeta: { @@ -240,9 +254,10 @@ export const SERVICE_CATALOG = { defaultVersion: "0.98.0", runtimeSupport: "docker-only", artifact: { - docker: { ownership: "supabase", repository: "postgres-meta", tagPrefix: "v" }, + docker: { repository: "pgmeta", tagPrefix: "v" }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["pgmetaPort"], }, studio: { @@ -250,35 +265,47 @@ export const SERVICE_CATALOG = { configKey: "studio", defaultVersion: "2026.08.17-sha-0c1da8f", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "studio" } }, - activation: { startup: "eager", activates: ["analytics"], owns: [] }, + artifact: { + docker: { repository: "studio" }, + }, + activation: { activates: ["analytics"], owns: [] }, + preparation: preparation(["eager"], "eager", ["pgmeta", "analytics"]), portFields: ["studioPort"], }, analytics: { name: "analytics", configKey: "analytics", - defaultVersion: "1.50.4", + defaultVersion: "v1.50.4", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "logflare" } }, - activation: { startup: "lazy", activates: ["vector"], owns: ["vector"] }, + artifact: { + docker: { repository: "analytics" }, + }, + activation: { activates: ["vector"], owns: ["vector"] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "vector"]), portFields: ["analyticsPort"], }, vector: { name: "vector", configKey: "vector", - defaultVersion: "0.53.0-alpine", + defaultVersion: "0.53.0", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "timberio/vector" } }, - activation: { startup: "lazy", activates: [], owns: [] }, + artifact: { + docker: { repository: "vector" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["analytics"]), portFields: [], }, pooler: { name: "pooler", configKey: "pooler", - defaultVersion: "2.9.7", + defaultVersion: "v2.9.10", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "supavisor" } }, - activation: { startup: "eager", activates: [], owns: [] }, + artifact: { + docker: { repository: "pooler" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager", ["postgres"]), portFields: ["poolerPort", "poolerApiPort"], }, } satisfies { readonly [Name in ServiceName]: ServiceCatalogEntry }; @@ -303,35 +330,14 @@ export const nativeReleaseForService = ( export const isDockerOnlyService = (service: ServiceName): boolean => SERVICE_CATALOG[service].runtimeSupport === "docker-only"; -const dockerTag = (service: ServiceName, version: string): string => { - const source = serviceMetadata(service).artifact.docker; - return `${source.tagPrefix ?? ""}${version}`; -}; +export const DEFAULT_SERVICE_POLICIES: Readonly< + Record> +> = Record.map(SERVICE_CATALOG, (metadata) => metadata.preparation.default); -export const dockerImageForArtifact = (service: ServiceName, version: string): string => { - const source = SERVICE_CATALOG[service].artifact.docker; - const repository = - source.ownership === "supabase" - ? `${SUPABASE_ECR_REGISTRY}/${source.repository}` - : source.repository; - return `${repository}:${dockerTag(service, version)}`; -}; +export const requiredPreparationDependencies = (service: ServiceName): ReadonlyArray => + serviceMetadata(service).preparation.dependencies; -export const dockerImageCandidatesForArtifact = ( - service: ServiceName, - version: string, -): ReadonlyArray => { - const source = SERVICE_CATALOG[service].artifact.docker; - const tag = dockerTag(service, version); - if (source.ownership === "upstream") { - return [`${source.repository}:${tag}`]; - } - return [ - `${SUPABASE_ECR_REGISTRY}/${source.repository}:${tag}`, - `${SUPABASE_DOCKER_HUB_REGISTRY}/${source.repository}:${tag}`, - `${SUPABASE_GHCR_REGISTRY}/${source.repository}:${tag}`, - ]; +export const dockerImageForArtifact = (service: ServiceName, version: string): string => { + const source = serviceMetadata(service).artifact.docker; + return `${SUPABASE_GHCR_REGISTRY}/${source.repository}:${source.tagPrefix ?? ""}${version}`; }; - -export const imageTagPrefixForService = (service: ServiceName): string | undefined => - serviceMetadata(service).artifact.docker.tagPrefix; diff --git a/packages/stack/src/ServicePorts.ts b/packages/stack/src/ServicePorts.ts index 6c1a7944e1..5674f87ce9 100644 --- a/packages/stack/src/ServicePorts.ts +++ b/packages/stack/src/ServicePorts.ts @@ -2,15 +2,13 @@ import { PORT_CATALOG, PORT_FIELDS, type PortField } from "./PortCatalog.ts"; import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; import type { StackConfig } from "./StackConfig.ts"; -export const serviceEnabledForConfig = ( - config: StackConfig, - service: keyof typeof SERVICE_CATALOG, -) => { +const serviceEnabledForConfig = (config: StackConfig, service: keyof typeof SERVICE_CATALOG) => { + if (config.servicePolicies?.[service] === "off") return false; if (service === "postgres" || service === "postgrest" || service === "auth") { return config[service === "postgres" ? "postgres" : service] !== false; } if (service === "edge-runtime") { - const mode = config.mode ?? "auto"; + const mode = config.mode ?? "native"; return ( !(mode === "native" && config.edgeRuntime === undefined) && config.edgeRuntime !== false && diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 9693b08d02..1a53cdb069 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -1,7 +1,7 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Schema, Stream } from "effect"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import { ResolvedFunctionsBundleSchema, type FunctionsReloadConfig, @@ -61,28 +61,44 @@ export class Stack extends Context.Service< name: string, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly stopService: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly restartService: ( name: string, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly reloadFunctions: ( opts?: FunctionsReloadConfig, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly reloadEdgeRuntime: ( opts: EdgeRuntimeReloadConfig, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly getState: (name: string) => Effect.Effect; readonly getAllStates: () => Effect.Effect>; diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 603a1407de..dd0a553046 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; +import { NodeServices } from "@effect/platform-node"; import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; import { mkdtempSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; -import * as TestClock from "effect/testing/TestClock"; +import { createServer, type Server } from "node:http"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Predicate, Stream } from "effect"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; -import { StackBuildError } from "./errors.ts"; +import { StackBuildError, StackReadinessError } from "./errors.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; import { functionsRuntimeConfigPath, type ResolvedFunctionsBundle } from "./functions.ts"; import type { AllocatedPorts, PortField, ResolvedPorts } from "./PortCatalog.ts"; import type { PortLease } from "./PortAllocator.ts"; import { StackServiceActivator } from "./ServiceActivation.ts"; import { Stack } from "./Stack.ts"; -import { localStackLayer } from "./LocalStack.ts"; +import { attachReadinessDiagnostics, localStackLayer } from "./LocalStack.ts"; import { StackPreparation } from "./StackPreparation.ts"; import { StackBuilder } from "./StackBuilder.ts"; import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; @@ -51,8 +51,22 @@ const defaultConfig: ResolvedStackConfig = { stackRoot: "/tmp/supabase-stack", runtimeRoot: "/tmp/supabase-runtime", projectDir: "/tmp/supabase-project", - mode: "native", - startupMode: "eager", + runtime: { mode: "native", containerRuntime: null }, + servicePolicies: { + postgres: "eager", + postgrest: "eager", + auth: "eager", + "edge-runtime": "off", + realtime: "off", + storage: "off", + imgproxy: "off", + mailpit: "off", + pgmeta: "off", + studio: "off", + analytics: "off", + vector: "off", + pooler: "off", + }, readiness: DEFAULT_STACK_READINESS_POLICY, readinessSource: "default", jwtSecret: testJwtSecret, @@ -100,7 +114,8 @@ const defaultConfig: ResolvedStackConfig = { const edgeRuntimeConfig: ResolvedStackConfig = { ...defaultConfig, - mode: "auto", + runtime: { mode: "docker", containerRuntime: "docker" }, + servicePolicies: { ...defaultConfig.servicePolicies, "edge-runtime": "eager" }, edgeRuntime: { enabled: true, port: defaultPorts.edgeRuntimePort, @@ -136,20 +151,51 @@ function setupLayer( config: ResolvedStackConfig = defaultConfig, portLease: PortLease = noopPortLease(config.ports), spawner = mockChildProcessSpawner(), + resolver = mockBinaryResolver(), ) { - const resolver = mockBinaryResolver(); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); const layer = localStackLayer(config, portLease).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return { layer, resolver, spawner }; } describe("Stack", () => { + it.effect("preserves defects and interruption while collecting readiness diagnostics", () => + Effect.gen(function* () { + const readinessError = new StackReadinessError({ + target: "stack", + timeoutMs: 10, + detail: "Timed out waiting for stack readiness", + }); + const defect = new Error("diagnostic state collection failed"); + + const defectExit = yield* attachReadinessDiagnostics( + readinessError, + Effect.die(defect), + Effect.succeed([]), + ).pipe(Effect.exit); + const interruptionExit = yield* attachReadinessDiagnostics( + readinessError, + Effect.interrupt, + Effect.succeed([]), + ).pipe(Effect.exit); + + expect(Exit.isFailure(defectExit)).toBe(true); + if (Exit.isFailure(defectExit)) { + expect(Cause.squash(defectExit.cause)).toBe(defect); + } + expect(Exit.isFailure(interruptionExit)).toBe(true); + if (Exit.isFailure(interruptionExit)) { + expect(Cause.hasInterruptsOnly(interruptionExit.cause)).toBe(true); + } + }), + ); + it.effect("getInfo returns correct URLs based on config", () => { const { layer } = setupLayer(); @@ -185,9 +231,22 @@ describe("Stack", () => { projectDir: runtimeRoot, runtimeRoot, functions: initialBundle, + postgrest: false, + auth: false, + servicePolicies: { + ...edgeRuntimeConfig.servicePolicies, + postgrest: "off", + auth: "off", + "edge-runtime": "lazy", + }, } satisfies ResolvedStackConfig; const graph = Effect.runSync( buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "unless-stopped", + }, { name: "edge-runtime", command: process.execPath, @@ -203,7 +262,10 @@ describe("Stack", () => { return { graph, cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([["edge-runtime", { visibility: "public" as const }]]), + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["edge-runtime", { visibility: "public" as const }], + ]), }; }), }); @@ -212,7 +274,7 @@ describe("Stack", () => { Layer.provide(builderLayer), Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); const readRuntimeConfig = Effect.promise(() => readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => @@ -223,8 +285,10 @@ describe("Stack", () => { return Effect.gen(function* () { const stack = yield* Stack; yield* stack.start(); + expect((yield* stack.getState("edge-runtime")).status).toBe("Dormant"); yield* stack.reloadFunctions({ functions: replacementBundle }); + expect((yield* stack.getState("edge-runtime")).status).toBe("Healthy"); expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); yield* stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }); @@ -244,8 +308,11 @@ describe("Stack", () => { functions: [replacementBundle.functions[0]!, replacementBundle.functions[0]!], }; expect( - (yield* stack.reloadFunctions({ functions: duplicateBundle }).pipe(Effect.flip))._tag, - ).toBe("StackBuildError"); + Predicate.isTagged( + yield* stack.reloadFunctions({ functions: duplicateBundle }).pipe(Effect.flip), + "StackBuildError", + ), + ).toBe(true); expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); // Replace the workspace directory with a plain file so the config write @@ -257,7 +324,7 @@ describe("Stack", () => { }); const failedBundle = functionsBundle(runtimeRoot, "failed-secret"); const error = yield* stack.reloadFunctions({ functions: failedBundle }).pipe(Effect.flip); - expect(error._tag).toBe("StackBuildError"); + expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); yield* Effect.promise(() => rm(runtimeDirectory)); yield* stack.reloadFunctions(); @@ -279,6 +346,97 @@ describe("Stack", () => { ); }); + it.live("merges overlapping function and Edge Runtime reloads", () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-race-")); + const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); + const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); + const config = { + ...edgeRuntimeConfig, + projectDir: runtimeRoot, + runtimeRoot, + functions: initialBundle, + postgrest: false, + auth: false, + servicePolicies: { + ...edgeRuntimeConfig.servicePolicies, + postgrest: "off", + auth: "off", + "edge-runtime": "lazy", + }, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { name: "postgres", command: process.execPath, restart: "unless-stopped" }, + { name: "edge-runtime", command: process.execPath, restart: "unless-stopped" }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.sync(() => { + return { + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["edge-runtime", { visibility: "public" as const }], + ]), + }; + }), + }); + const preparationStarted = Deferred.makeUnsafe(); + const allowPreparation = Deferred.makeUnsafe(); + let blockNextSpawn = false; + const resolver = mockBinaryResolver(); + const spawner = mockChildProcessSpawner({ + beforeSpawn: () => { + if (!blockNextSpawn) return Effect.void; + blockNextSpawn = false; + return Deferred.succeed(preparationStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowPreparation)), + ); + }, + }); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + const readRuntimeConfig = Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => + JSON.parse(contents), + ), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + blockNextSpawn = true; + + const functionsReload = yield* stack + .reloadFunctions({ functions: replacementBundle }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(preparationStarted); + + // Both requests join the same gated preparation before either can commit. + // The later Edge Runtime commit must preserve the Functions update. + const edgeReload = yield* stack + .reloadEdgeRuntime({ edgeRuntime: { env: { CONCURRENT: "edge-value" } } }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(allowPreparation, undefined); + yield* Fiber.join(functionsReload); + yield* Fiber.join(edgeReload); + + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + expect((yield* stack.getState("edge-runtime")).status).toBe("Healthy"); + yield* stack.dispose(); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), + Effect.timeout("5 seconds"), + ); + }); + it.effect("getInfo returns valid JWT tokens", () => { const { layer } = setupLayer(); @@ -423,39 +581,6 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); - it.effect("emits Downloading when a service fetches assets before startup", () => { - const resolver = mockBinaryResolver({ - downloadedServices: ["postgres"], - downloadDelayMs: 20, - }); - const spawner = mockChildProcessSpawner(); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - ); - const providedLayer = layer.pipe( - Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - const statesFiber = yield* stack.allStateChanges().pipe( - Stream.filter((state) => state.name === "postgres"), - Stream.take(2), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); - - const startFiber = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); - const states = yield* Fiber.join(statesFiber); - yield* Fiber.interrupt(startFiber); - - expect(states.map((state) => state.status)).toContain("Downloading"); - }).pipe(Effect.provide(providedLayer)); - }); - it.live("starts the readiness deadline after artifact preparation", () => { const resolver = mockBinaryResolver({ downloadedServices: ["postgres"], @@ -474,7 +599,7 @@ describe("Stack", () => { Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -487,13 +612,33 @@ describe("Stack", () => { }).pipe(Effect.provide(layer), Effect.scoped, Effect.timeout("5 seconds")); }); + it.effect("rejects unsupported native services before resource planning", () => { + const config = { + ...edgeRuntimeConfig, + runtime: { mode: "native", containerRuntime: null }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports)); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* stack.start().pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + if (Predicate.isTagged(error, "StackBuildError")) + expect(error.detail).toContain("edge-runtime"); + }).pipe(Effect.provide(layer)); + }); + it.effect("getState fails for internal helper services", () => { const { layer } = setupLayer(); return Effect.gen(function* () { const stack = yield* Stack; const exit = yield* stack.getState("postgres-init").pipe(Effect.exit); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -519,14 +664,26 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); - it.effect("startService fails with ServiceNotFoundError for unknown service", () => { - const { layer } = setupLayer(); + it.live("startService fails with ServiceNotFoundError for unknown service", () => { + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config); return Effect.gen(function* () { const stack = yield* Stack; + yield* stack.start(); const exit = yield* stack.startService("nonexistent").pipe(Effect.exit); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "ServiceNotFoundError" }); + } }).pipe(Effect.provide(layer)); }); @@ -547,7 +704,7 @@ describe("Stack", () => { ); const providedLayer = layer.pipe( Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -561,6 +718,40 @@ describe("Stack", () => { }).pipe(Effect.provide(providedLayer)); }); + it.live("disposal fails a cold eager start with a typed build error", () => { + return Effect.gen(function* () { + const preparationStarted = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" + ? Deferred.succeed(preparationStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + }); + const { layer } = setupLayer( + defaultConfig, + noopPortLease(defaultConfig.ports), + undefined, + resolver, + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const starting = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(preparationStarted); + + const disposing = yield* stack.dispose().pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(disposing); + + const startExit = yield* Fiber.await(starting); + expect(Exit.isFailure(startExit)).toBe(true); + if (Exit.isFailure(startExit)) { + expect(Cause.squash(startExit.cause)).toMatchObject({ _tag: "StackBuildError" }); + } + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")); + }); + it.live("can retry start after a build failure before services start", () => { let buildAttempts = 0; const graph = Effect.runSync( @@ -584,23 +775,267 @@ describe("Stack", () => { const resolver = mockBinaryResolver(); const spawner = mockChildProcessSpawner(); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( + const config = { + ...defaultConfig, + postgrest: false, + auth: false, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "off", + auth: "off", + }, + } satisfies ResolvedStackConfig; + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( Layer.provide(builderLayer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { const stack = yield* Stack; - expect(Exit.isFailure(yield* stack.start().pipe(Effect.exit))).toBe(true); yield* stack.start(); - expect(buildAttempts).toBe(2); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); + it.live("rejects an all-eager graph that omits an enabled public service", () => { + const graph = Effect.runSync( + buildGraph([{ name: "postgres", command: "true", restart: "no" }]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([["postgres", { visibility: "public" as const }]]), + }), + }); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + const exit = yield* stack.start().pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.findErrorOption(exit.cause)).toMatchObject({ + _tag: "Some", + value: { + _tag: "StackBuildError", + detail: "Prepared graph does not contain enabled service postgrest", + }, + }); + } + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("can retry start after asset preparation fails before services start", () => { + const resolver = mockBinaryResolver({ + failOnceServices: ["postgres"], + }); + const config = { + ...defaultConfig, + postgrest: false, + auth: false, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "off", + auth: "off", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + return Effect.gen(function* () { + const stack = yield* Stack; + expect(Exit.isFailure(yield* stack.start().pipe(Effect.exit))).toBe(true); + yield* stack.start(); + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("restarts activated companions after stopping the stack", () => { + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "postgrest", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "pgmeta", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "studio", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "analytics", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "vector", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["postgrest", { visibility: "public" as const }], + ["pgmeta", { visibility: "public" as const }], + ["studio", { visibility: "public" as const }], + ["analytics", { visibility: "public" as const }], + ["vector", { visibility: "public" as const }], + ]), + }), + }); + const config = { + ...defaultConfig, + runtime: { mode: "docker", containerRuntime: "docker" }, + pgmeta: { port: defaultPorts.pgmetaPort, version: DEFAULT_VERSIONS.pgmeta }, + studio: { + port: defaultPorts.studioPort, + apiUrl: "http://127.0.0.1:54321", + version: DEFAULT_VERSIONS.studio, + }, + analytics: { + port: defaultPorts.analyticsPort, + version: DEFAULT_VERSIONS.analytics, + backend: "postgres", + apiKey: "test-api-key", + }, + vector: { version: DEFAULT_VERSIONS.vector }, + servicePolicies: { + ...defaultConfig.servicePolicies, + auth: "off", + postgrest: "lazy", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + }, + auth: false, + } satisfies ResolvedStackConfig; + const { resolver, spawner } = setupLayer(config, noopPortLease(config.ports)); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stop(); + yield* stack.start(); + + expect((yield* stack.getState("studio")).status).toBe("Healthy"); + expect((yield* stack.getState("analytics")).status).toBe("Healthy"); + expect((yield* stack.getState("vector")).status).toBe("Healthy"); + }).pipe(Effect.provide(layer), Effect.timeout("10 seconds")); + }); + + it.live("rejects a cached start when disposal begins during startup", () => + Effect.gen(function* () { + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + const config = { + ...defaultConfig, + postgrest: false, + auth: false, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "off", + auth: "off", + }, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([{ name: "postgres", command: "true", restart: "no" }]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([["postgres", { visibility: "public" as const }]]), + }), + }); + let gateNextStart = false; + const portLease: PortLease = { + ports: config.ports, + reserve: () => { + if (!gateNextStart) return Effect.void; + gateNextStart = false; + return Deferred.succeed(startEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseStart)), + ); + }, + release: () => Effect.void, + releaseAll: Effect.void, + }; + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, portLease).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stop(); + + gateNextStart = true; + const holder = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(startEntered); + const disposing = yield* stack.dispose().pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.succeed(releaseStart, undefined); + const holderExit = yield* Fiber.await(holder); + expect(Exit.isFailure(holderExit)).toBe(true); + if (Exit.isFailure(holderExit)) { + expect(Cause.squash(holderExit.cause)).toMatchObject({ _tag: "StackBuildError" }); + } + yield* Fiber.join(disposing); + + expect((yield* stack.getState("postgres")).status).toBe("Stopped"); + const afterDisposal = yield* stack.start().pipe(Effect.flip); + expect(Predicate.isTagged(afterDisposal, "StackBuildError")).toBe(true); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + it.live("a partial startup failure disposes resources from services already started", () => { let cleaned = false; const spawner = mockChildProcessSpawner({ @@ -641,6 +1076,8 @@ describe("Stack", () => { const layer = localStackLayer( { ...defaultConfig, + auth: false, + servicePolicies: { ...defaultConfig.servicePolicies, auth: "off" }, readiness: { mode: "finite", timeoutMs: 100 }, readinessSource: "configured", }, @@ -649,7 +1086,7 @@ describe("Stack", () => { Layer.provide(builderLayer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -663,58 +1100,222 @@ describe("Stack", () => { }); it.live("lazy startup starts direct services without starting HTTP backends", () => { - const { layer, spawner } = setupLayer({ ...defaultConfig, startupMode: "lazy" }); + const { layer } = setupLayer({ + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + storage: "lazy", + imgproxy: "lazy", + }, + }); return Effect.gen(function* () { const stack = yield* Stack; yield* stack.start(); yield* stack.waitAllReady(); - expect( - spawner.spawned.some((record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"bash"'), + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); + + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("prepares a dormant service before restarting it", () => + Effect.gen(function* () { + const resolver = mockBinaryResolver({ downloadedServices: ["postgrest"] }); + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + const stateChanges = yield* stack.stateChanges("postgrest"); + const downloading = yield* stateChanges.pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const running = yield* stateChanges.pipe( + Stream.filter((state) => state.status === "Running"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const restarting = yield* stack + .restartService("postgrest") + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(Option.isSome(yield* Fiber.join(downloading))).toBe(true); + expect(Option.isSome(yield* Fiber.join(running))).toBe(true); + + yield* Fiber.interrupt(restarting); + yield* stack.stop(); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + + it.live("does not restart a service after the stack stops during preparation", () => + Effect.gen(function* () { + const allowPreparation = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["postgrest"], + beforeResolve: ({ service }) => + service === "postgrest" ? Deferred.await(allowPreparation) : Effect.void, + }); + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + const downloading = yield* (yield* stack.stateChanges("postgrest")).pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const restarting = yield* stack + .restartService("postgrest") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(downloading); + const running = yield* (yield* stack.stateChanges("postgrest")).pipe( + Stream.filter((state) => state.status === "Running"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + + yield* stack.stop(); + yield* Deferred.succeed(allowPreparation, undefined); + const outcome = yield* Effect.race( + Fiber.join(restarting).pipe( + Effect.exit, + Effect.map((exit) => ({ type: "restart" as const, exit })), ), - ), - ).toBe(true); - expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); - expect(spawner.spawned.some((record) => record.command.endsWith("/postgrest"))).toBe(false); + Fiber.join(running).pipe(Effect.as({ type: "resurrected" as const })), + ); + + expect(outcome.type).toBe("restart"); + if (outcome.type === "restart") expect(Exit.isFailure(outcome.exit)).toBe(true); + expect((yield* stack.getState("postgrest")).status).not.toBe("Running"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + + it.live("lazy activation restores dormant state after a stopped transitive dependency", () => { + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; + const resolver = mockBinaryResolver({ downloadedServices: ["postgrest"] }); + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + return Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + yield* stack.stopService("postgres"); + + const downloading = yield* (yield* stack.stateChanges("postgrest")).pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const error = yield* activator.activate("postgrest").pipe(Effect.flip); + expect(Option.isSome(yield* Fiber.join(downloading))).toBe(true); + expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); + if (Predicate.isTagged(error, "StackBuildError")) { + expect(error.detail).toContain("postgres was explicitly stopped"); + } + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); yield* stack.stop(); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); - it.live("lazy activation honors explicitly stopped transitive dependencies", () => { - const config: ResolvedStackConfig = { + it.live("preserves an explicit stop during in-flight lazy activation", () => { + const config = { ...defaultConfig, - mode: "auto", - startupMode: "lazy", - storage: { - port: defaultPorts.storagePort, - dataDir: "/tmp/supabase/storage", - fileSizeLimit: "50MiB", - s3ProtocolEnabled: true, - version: DEFAULT_VERSIONS.storage, - }, - imgproxy: { - port: defaultPorts.imgproxyPort, - version: DEFAULT_VERSIONS.imgproxy, - }, - }; - const { layer } = setupLayer(config); + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; + const allowDownload = Deferred.makeUnsafe(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" ? Deferred.await(allowDownload) : Effect.void, + }); + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); return Effect.gen(function* () { const stack = yield* Stack; const activator = yield* StackServiceActivator; yield* stack.start(); - yield* stack.stopService("imgproxy"); - const error = yield* activator.activate("storage").pipe(Effect.flip); + const authChanges = yield* stack.stateChanges("auth"); + const downloading = yield* authChanges.pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const stopped = yield* authChanges.pipe( + Stream.filter((state) => state.status === "Stopped"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const activation = yield* activator + .activate("auth") + .pipe(Effect.forkChild({ startImmediately: true })); - expect(error._tag).toBe("StackBuildError"); - if (error._tag === "StackBuildError") { - expect(error.detail).toContain("imgproxy was explicitly stopped"); + expect(Option.isSome(yield* Fiber.join(downloading))).toBe(true); + yield* stack.stopService("auth"); + expect(Option.isSome(yield* Fiber.join(stopped))).toBe(true); + yield* Deferred.succeed(allowDownload, undefined); + + const activationExit = yield* Fiber.await(activation); + expect(Exit.isFailure(activationExit)).toBe(true); + if (Exit.isFailure(activationExit)) { + const error = Cause.squash(activationExit.cause); + expect(error).toMatchObject({ _tag: "StackBuildError" }); + if (error instanceof StackBuildError) { + expect(error.detail).toContain("auth was explicitly stopped"); + } } + expect((yield* stack.getState("auth")).status).toBe("Stopped"); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("rejects stopping a service before start without affecting a later start", () => { + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* stack.stopService("auth").pipe(Effect.flip); + + expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true); + if (Predicate.isTagged(error, "StackNotRunningError")) expect(error.phase).toBe("idle"); + + yield* stack.start(); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); yield* stack.stop(); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); @@ -730,7 +1331,15 @@ describe("Stack", () => { ? Deferred.succeed(spawnStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, }); - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + mailpit: "eager", + }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { @@ -747,7 +1356,7 @@ describe("Stack", () => { .activate("auth") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(spawnStarted); - expect((yield* Fiber.join(activeStateFiber))._tag).toBe("Some"); + expect(Option.isSome(yield* Fiber.join(activeStateFiber))).toBe(true); expect((yield* stack.getState("auth")).status).not.toBe("Dormant"); const readyFiber = yield* stack @@ -776,7 +1385,10 @@ describe("Stack", () => { ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, }); - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { @@ -803,8 +1415,15 @@ describe("Stack", () => { const mailpitReleaseStarted = yield* Deferred.make(); const config = { ...defaultConfig, - mode: "auto", - startupMode: "lazy", + runtime: { mode: "docker", containerRuntime: "docker" }, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "off", + auth: "off", + mailpit: "eager", + }, + postgrest: false, + auth: false, mailpit: { port: defaultPorts.mailpitPort, smtpPort: defaultPorts.mailpitSmtpPort, @@ -827,7 +1446,40 @@ describe("Stack", () => { : Effect.void, releaseAll: Effect.void, }; - const { layer } = setupLayer(config, lease); + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "unless-stopped", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "mailpit", + command: process.execPath, + restart: "unless-stopped", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["mailpit", { visibility: "public" as const }], + ]), + }), + }); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, lease).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); yield* Effect.gen(function* () { const stack = yield* Stack; @@ -836,7 +1488,10 @@ describe("Stack", () => { yield* Deferred.await(mailpitReleaseStarted); yield* Deferred.succeed(allowPostgresRelease, undefined); - yield* Fiber.interrupt(starting); + yield* Fiber.join(starting); + + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + expect((yield* stack.getState("mailpit")).status).toBe("Healthy"); yield* stack.stop(); }).pipe(Effect.provide(layer)); @@ -857,7 +1512,10 @@ describe("Stack", () => { ) : Effect.void, }); - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { @@ -879,109 +1537,67 @@ describe("Stack", () => { expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); const error = yield* activator.activate("auth").pipe(Effect.flip); - expect(error._tag).toBe("StackNotRunningError"); + expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); + if (Predicate.isTagged(error, "StackBuildError")) { + expect(error.detail).toContain("disposal has begun"); + } }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); - it.effect("uses the stack readiness deadline for explicit lazy activation and cleans up", () => + it.live("dispose cancels in-flight lazy preparation", () => Effect.gen(function* () { - const authHealthServer = yield* Effect.acquireRelease( - Effect.sync(() => - Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: () => new Response("unhealthy", { status: 503 }), - }), - ), - (server) => Effect.sync(() => server.stop(true)), - ); - const authPort = authHealthServer.port; - if (authPort === undefined) { - throw new Error("Expected the auth health test server to bind a TCP port"); - } - const authConfig = defaultConfig.auth; - if (authConfig === false) { - throw new Error("Expected auth to be enabled in the default test config"); - } - const postgresProbeStarted = yield* Deferred.make(); - const postgresInitStarted = yield* Deferred.make(); - const authSpawnStarted = yield* Deferred.make(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => { - if (record.command.endsWith("/pg_isready")) { - return Deferred.succeed(postgresProbeStarted, undefined).pipe(Effect.asVoid); - } - if ( - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"bash","args":["-c"'), - ) - ) { - return Deferred.succeed(postgresInitStarted, undefined).pipe(Effect.asVoid); - } - return record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.asVoid) - : Effect.void; - }, + const preparationStarted = yield* Deferred.make(); + const disposed = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" + ? Deferred.succeed(preparationStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, }); - let releasedAll = false; const config = { ...defaultConfig, - startupMode: "lazy", - ports: { ...defaultPorts, authPort }, - auth: { ...authConfig, port: authPort }, - readiness: { mode: "finite", timeoutMs: 100 }, - readinessSource: "configured", + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, } satisfies ResolvedStackConfig; - const lease: PortLease = { + const lease = { ...noopPortLease(config.ports), - releaseAll: Effect.sync(() => { - releasedAll = true; - }), - }; - const { layer } = setupLayer(config, lease, spawner); + releaseAll: Deferred.succeed(disposed, undefined).pipe(Effect.asVoid), + } satisfies PortLease; + const { layer } = setupLayer(config, lease, mockChildProcessSpawner(), resolver); yield* Effect.gen(function* () { const stack = yield* Stack; - const activator = yield* StackServiceActivator; - const start = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(postgresProbeStarted); - yield* TestClock.adjust("10 millis"); - yield* Deferred.await(postgresInitStarted); - yield* TestClock.adjust("89 millis"); - yield* Fiber.join(start); - - const activation = yield* activator - .activate("auth") + yield* stack.start(); + const activation = yield* stack + .startService("auth") .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(authSpawnStarted); - yield* TestClock.adjust("100 millis"); - const error = yield* Fiber.join(activation).pipe(Effect.flip); - - expect(error._tag).toBe("StackReadinessError"); - if (error._tag === "StackReadinessError") { - expect(error.target).toBe("auth"); - expect(error.timeoutMs).toBe(100); + yield* Deferred.await(preparationStarted); + const secondActivation = yield* stack + .startService("auth") + .pipe(Effect.forkChild({ startImmediately: true })); + + const disposing = yield* stack.dispose().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(disposed); + yield* Fiber.join(disposing); + + const activationExit = yield* Fiber.await(activation); + const secondActivationExit = yield* Fiber.await(secondActivation); + expect(Exit.isFailure(activationExit)).toBe(true); + expect(Exit.isFailure(secondActivationExit)).toBe(true); + if (Exit.isFailure(activationExit)) { + expect(Cause.squash(activationExit.cause)).toMatchObject({ + _tag: "StackBuildError", + detail: "Stack disposed during asset preparation", + }); } - expect(releasedAll).toBe(true); - const spawnCountAfterDisposal = spawner.spawned.length; - expect((yield* activator.activate("postgres").pipe(Effect.flip))._tag).toBe( - "StackNotRunningError", - ); - for (const operation of [ - stack.start(), - stack.startService("postgres"), - stack.stopService("postgres"), - stack.restartService("postgres"), - stack.reloadFunctions(), - stack.reloadEdgeRuntime({ edgeRuntime: {} }), - ]) { - expect((yield* operation.pipe(Effect.flip))._tag).toBe("StackBuildError"); + if (Exit.isFailure(secondActivationExit)) { + expect(Cause.squash(secondActivationExit.cause)).toMatchObject({ + _tag: "StackBuildError", + detail: "Stack disposed during asset preparation", + }); } - yield* stack.stop(); - expect(spawner.spawned).toHaveLength(spawnCountAfterDisposal); + expect((yield* stack.getState("auth")).status).not.toBe("Downloading"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped), ); @@ -991,44 +1607,97 @@ describe("Stack", () => { const spawnStarted = yield* Deferred.make(); const spawner = mockChildProcessSpawner({ beforeSpawn: (record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(spawnStarted, undefined).pipe(Effect.andThen(Effect.never)) + record.command === "/cache/auth" + ? Deferred.succeed(spawnStarted, undefined) : Effect.void, }); let releasedAll = false; const config = { ...defaultConfig, - startupMode: "lazy", + postgrest: false, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "off", auth: "lazy" }, readiness: { mode: "infinite" }, readinessSource: "configured", } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: "true", + restart: "no", + }, + { + name: "auth", + command: "/cache/auth", + dependencies: [{ service: "postgres", condition: "started" }], + restart: "unless-stopped", + healthCheck: { + probe: { + _tag: "Http", + host: "127.0.0.1", + port: 1, + path: "/health", + scheme: "http", + }, + periodSeconds: 10, + }, + hooks: [{ on: "started", run: (log) => log("stderr", "auth startup failed") }], + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["auth", { visibility: "public" as const }], + ]), + }), + }); const lease: PortLease = { ...noopPortLease(config.ports), releaseAll: Effect.sync(() => { releasedAll = true; }), }; - const { layer } = setupLayer(config, lease, spawner); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, lease).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); yield* Effect.gen(function* () { const stack = yield* Stack; const activator = yield* StackServiceActivator; yield* stack.start(); + const authLog = yield* stack + .subscribeLogs("auth") + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); const activation = yield* activator .activate("auth") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(spawnStarted); + const authLogEntry = yield* Fiber.join(authLog); + expect(authLogEntry).toMatchObject({ + _tag: "Some", + value: { line: "auth startup failed", service: "auth" }, + }); const error = yield* stack .waitAllReady({ mode: "finite", timeoutMs: 25 }) .pipe(Effect.flip); - expect(error._tag).toBe("StackReadinessError"); - if (error._tag === "StackReadinessError") { + expect(Predicate.isTagged(error, "StackReadinessError")).toBe(true); + if (Predicate.isTagged(error, "StackReadinessError")) { expect(error.target).toBe("stack"); expect(error.timeoutMs).toBe(25); + expect(error.detail).toContain("Non-ready services: auth:"); + expect(error.detail).toContain("Recent logs"); + expect(error.detail).toContain("auth startup failed"); } expect(releasedAll).toBe(true); yield* Fiber.interrupt(activation); @@ -1039,15 +1708,22 @@ describe("Stack", () => { it.live("does not revive stopped lazy dependents when restarting a dependency", () => { return Effect.gen(function* () { const authHealthServer = yield* Effect.acquireRelease( - Effect.sync(() => - Bun.serve({ - port: 0, - fetch: () => new Response("ok"), - }), + Effect.tryPromise( + () => + new Promise((resolve, reject) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), ), - (server) => Effect.sync(() => server.stop(true)), + (server) => + Effect.tryPromise(() => new Promise((resolve) => server.close(() => resolve()))), ); - const authPort = authHealthServer.port; + const address = authHealthServer.address(); + const authPort = typeof address === "object" && address !== null ? address.port : undefined; if (authPort === undefined) { throw new Error("Expected the auth health test server to bind a TCP port"); } @@ -1057,7 +1733,7 @@ describe("Stack", () => { } const { layer, spawner } = setupLayer({ ...defaultConfig, - startupMode: "lazy", + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, ports: { ...defaultPorts, authPort }, auth: { ...authConfig, port: authPort }, }); @@ -1085,23 +1761,106 @@ describe("Stack", () => { }); it.live("lazy readiness fails fast before a service is activated", () => { - const { layer } = setupLayer({ ...defaultConfig, startupMode: "lazy" }); + const { layer } = setupLayer({ + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + }); return Effect.gen(function* () { const stack = yield* Stack; const beforeStart = yield* stack.waitAllReady().pipe(Effect.flip); - expect(beforeStart._tag).toBe("StackBuildError"); + expect(Predicate.isTagged(beforeStart, "StackBuildError")).toBe(true); yield* stack.start(); const authNotActivated = yield* stack.waitReady("auth").pipe(Effect.flip); - expect(authNotActivated._tag).toBe("ServiceReadyError"); + expect(Predicate.isTagged(authNotActivated, "ServiceReadyError")).toBe(true); yield* stack.stop(); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); + it.live( + "restores dormant lazy services after preparation failure and retries successfully", + () => { + return Effect.gen(function* () { + const healthServer = yield* Effect.acquireRelease( + Effect.tryPromise( + () => + new Promise((resolve, reject) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.tryPromise(() => new Promise((resolve) => server.close(() => resolve()))), + ); + const address = healthServer.address(); + const postgrestPort = typeof address === "object" && address !== null ? address.port : 0; + if (postgrestPort === 0) throw new Error("Expected a PostgREST health port"); + const basePostgrest = defaultConfig.postgrest; + if (basePostgrest === false) throw new Error("Expected PostgREST in the default config"); + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "off", + }, + auth: false, + ports: { + ...defaultConfig.ports, + postgrestPort, + postgrestAdminPort: postgrestPort + 1, + }, + postgrest: { + ...basePostgrest, + port: postgrestPort, + adminPort: postgrestPort + 1, + }, + } satisfies ResolvedStackConfig; + const failingResolver = mockBinaryResolver({ failOnceServices: ["postgrest"] }); + const stackPreparationLayer = StackPreparation.layer.pipe( + Layer.provide(failingResolver.layer), + ); + const testLayer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(StackBuilder.layer), + Layer.provide(stackPreparationLayer), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + expect(["Running", "Healthy", "Initializing"]).toContain( + (yield* stack.getState("postgres")).status, + ); + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); + const first = yield* activator.activate("postgrest").pipe(Effect.flip); + expect(Predicate.isTagged(first, "StackBuildError")).toBe(true); + expect(["Running", "Healthy", "Initializing"]).toContain( + (yield* stack.getState("postgres")).status, + ); + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); + + yield* activator.activate("postgrest"); + expect(["Running", "Healthy"]).toContain((yield* stack.getState("postgrest")).status); + yield* stack.stop(); + }).pipe(Effect.provide(testLayer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")); + }, + ); + it.live("keeps unactivated services dormant after a stop and start cycle", () => { - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config); return Effect.gen(function* () { @@ -1118,7 +1877,10 @@ describe("Stack", () => { }); it.live("rejects a cached activation after the stack has stopped", () => { - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config); return Effect.gen(function* () { @@ -1128,12 +1890,15 @@ describe("Stack", () => { yield* stack.stop(); const error = yield* activator.activate("postgres").pipe(Effect.flip); - expect(error._tag).toBe("StackNotRunningError"); + expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); it.live("preserves an explicitly stopped service across a stack restart", () => { - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config); return Effect.gen(function* () { @@ -1167,7 +1932,13 @@ describe("Stack", () => { }), releaseAll: Effect.void, }; - const { layer } = setupLayer({ ...defaultConfig, startupMode: "lazy" }, lease); + const { layer } = setupLayer( + { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + }, + lease, + ); return Effect.gen(function* () { const stack = yield* Stack; diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index b45e351f1b..1c4f288ea3 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -1,21 +1,26 @@ +import { join } from "node:path"; import { buildGraph } from "@supabase/process-compose"; import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; -import { Effect, Layer, Context } from "effect"; +import { Context, Effect, FileSystem, Layer, Scope } from "effect"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { StackBuildError } from "./errors.ts"; import { generateJwks } from "./JwtGenerator.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import { shortTempPrefixRoot } from "./paths.ts"; import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { makeEdgeRuntimeServiceDocker, - makeEdgeRuntimeServiceNative, + prepareEdgeRuntimeBootstrap, } from "./services/edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; import { makeMailpitServiceDocker } from "./services/mailpit.ts"; import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; import { makePoolerServiceDocker } from "./services/pooler.ts"; -import { makePostgresInitService } from "./services/postgres-init.ts"; +import { + makePostgresInitService, + makePostgresInitServiceDocker, +} from "./services/postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; import { makeRealtimeServiceDocker } from "./services/realtime.ts"; @@ -48,6 +53,9 @@ export interface BuildResult { const dockerOnlyServices = SERVICE_NAMES.filter( (service) => serviceMetadata(service).runtimeSupport === "docker-only", ); +const nativeServices = SERVICE_NAMES.filter( + (service) => serviceMetadata(service).runtimeSupport !== "docker-only", +); // Serial health-check paths used by dependency waits; keep each path aligned // with the corresponding service's transitive dependencies. @@ -57,14 +65,12 @@ const analyticsStartupPath: ReadonlyArray = ["postgres", "analytics const postgresDependencyTimeoutSeconds = dependencyTimeoutSecondsForServices(postgresStartupPath); -const dependsOnPostgres = (hasPostgresInit: boolean): ReadonlyArray => - hasPostgresInit - ? [{ service: "postgres-init", condition: "completed" }] - : [{ service: "postgres", condition: "healthy" }]; +const postgresDependencies: ReadonlyArray = [ + { service: "postgres-init", condition: "completed" }, +]; const publicServiceProjection = ( defs: ReadonlyArray, - hasPostgresInit: boolean, ): StackServiceProjectionCatalog => { const serviceProjection: Map< string, @@ -75,13 +81,11 @@ const publicServiceProjection = ( } > = new Map(defs.map((def) => [def.name, { visibility: "public" as const }] as const)); - if (hasPostgresInit) { - serviceProjection.set("postgres-init", { - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); - } + serviceProjection.set("postgres-init", { + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); return serviceProjection; }; @@ -97,6 +101,49 @@ const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => const resolvedConfigForService = (config: ResolvedStackConfig, service: ServiceName) => config[serviceMetadata(service).configKey]; +const prepareNativePostgresAlias = ( + preparedPath: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const aliasRoot = yield* Effect.acquireRelease( + fs.makeTempDirectory({ + directory: shortTempPrefixRoot(), + prefix: "supabase-stack-postgres-", + }), + (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignore), + ).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to create a private native PostgreSQL binary directory", + cause, + }), + ), + ); + const aliasPath = join(aliasRoot, "bundle"); + if (/\s/.test(aliasPath) || (process.platform !== "darwin" && process.platform !== "linux")) { + yield* fs.remove(aliasRoot, { recursive: true, force: true }).pipe(Effect.ignore); + return yield* Effect.fail( + new StackBuildError({ + detail: "Native PostgreSQL requires a Unix temporary path without whitespace", + reason: "invalid_config", + }), + ); + } + + yield* fs.symlink(preparedPath, aliasPath).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to publish the native PostgreSQL binary alias", + cause, + }), + ), + ); + return aliasPath; + }); + export const validateResolvedConfig = ( config: ResolvedStackConfig, ): Effect.Effect => @@ -110,14 +157,14 @@ export const validateResolvedConfig = ( ); } - if (config.mode === "native") { + if (config.runtime.mode === "native") { const enabledDockerOnly = dockerOnlyServices.filter( (service) => resolvedConfigForService(config, service) !== false, ); if (enabledDockerOnly.length > 0) { return yield* Effect.fail( new StackBuildError({ - detail: `mode "native" only supports postgres, auth, and postgrest. Disable ${enabledDockerOnly.join(", ")} or switch to "auto" or "docker".`, + detail: `Native mode supports only ${nativeServices.join(", ")}. Disable ${enabledDockerOnly.join(", ")} or select Docker mode with a usable Docker or Podman runtime.`, reason: "invalid_config", }), ); @@ -154,7 +201,9 @@ export const validateResolvedConfig = ( export const enabledServicesForConfig = (config: ResolvedStackConfig): ReadonlyArray => SERVICE_NAMES.filter( - (service) => service === "postgres" || resolvedConfigForService(config, service) !== false, + (service) => + config.servicePolicies?.[service] !== "off" && + resolvedConfigForService(config, service) !== false, ); export const versionsForConfig = (config: ResolvedStackConfig): Partial => { @@ -198,18 +247,13 @@ const requirePreparedDockerImage = ( ), ); -export const nativePostgresNeedsDockerAccess = ( - postgresResolution: ServiceResolution, - dockerServicesEnabled: boolean, -): boolean => postgresResolution.type === "binary" && dockerServicesEnabled; - export class StackBuilder extends Context.Service< StackBuilder, { readonly build: ( config: ResolvedStackConfig, prepared: PreparedStackArtifacts, - ) => Effect.Effect; + ) => Effect.Effect; } >()("local/StackBuilder") { static layer: Layer.Layer = Layer.succeed(this, { @@ -217,48 +261,45 @@ export class StackBuilder extends Context.Service< Effect.gen(function* () { yield* validateResolvedConfig(config); + const requireContainerRuntime = Effect.suspend(() => + config.runtime.mode === "native" + ? Effect.fail( + new StackBuildError({ + detail: "A Docker service requires a selected container runtime", + reason: "invalid_config", + }), + ) + : Effect.succeed(config.runtime.containerRuntime), + ); + const platform = yield* detectPlatform; const serviceHost = dockerHostAddress(platform.os); const projectDir = config.projectDir; const postgresResolution = yield* requirePreparedResolution(prepared, "postgres"); + if (postgresResolution.type === "docker") { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(config.postgres.dataDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to prepare the PostgreSQL data directory", + cause, + }), + ), + ); + } + const authResolution = config.auth === false ? false : yield* requirePreparedResolution(prepared, "auth"); - const edgeRuntimeResolution = - config.edgeRuntime === false - ? false - : yield* requirePreparedResolution(prepared, "edge-runtime"); - const postgrestResolution = config.postgrest === false ? false : yield* requirePreparedResolution(prepared, "postgrest"); - const dockerServicesEnabled = - config.realtime !== false || - config.storage !== false || - config.imgproxy !== false || - config.mailpit !== false || - config.pgmeta !== false || - config.studio !== false || - config.analytics !== false || - config.vector !== false || - config.pooler !== false || - (edgeRuntimeResolution !== false && edgeRuntimeResolution.type === "docker") || - (authResolution !== false && authResolution.type === "docker") || - (postgrestResolution !== false && postgrestResolution.type === "docker"); - - const needsDockerAccess = nativePostgresNeedsDockerAccess( - postgresResolution, - dockerServicesEnabled, - ); - const hasPostgresInit = postgresResolution.type === "binary"; - const postgresDeps = dependsOnPostgres(hasPostgresInit); - const postgresInitCompletionBudgetSeconds = hasPostgresInit - ? POSTGRES_INIT_COMPLETION_BUDGET_SECONDS - : 0; + const postgresInitCompletionBudgetSeconds = POSTGRES_INIT_COMPLETION_BUDGET_SECONDS; const postgresConsumerDependencyTimeoutSeconds = postgresDependencyTimeoutSeconds + postgresInitCompletionBudgetSeconds; const storageDependencyTimeoutSeconds = @@ -270,44 +311,55 @@ export class StackBuilder extends Context.Service< const jwtJwks = generateJwks(config.jwtSecret); const identity = stackIdentity(config); + const postgresService = + postgresResolution.type === "binary" + ? makePostgresService({ + binPath: yield* prepareNativePostgresAlias(postgresResolution.path), + dataDir: config.postgres.dataDir, + port: config.dbPort, + cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], + }) + : makePostgresServiceDocker({ + runtime: yield* requireContainerRuntime, + image: postgresResolution.image, + dataDir: config.postgres.dataDir, + port: config.dbPort, + platformOs: platform.os, + identity, + cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], + }); + const defs: Array = [ { - ...(postgresResolution.type === "binary" - ? makePostgresService({ - binPath: postgresResolution.path, - dataDir: config.postgres.dataDir, - port: config.dbPort, - dockerAccessible: needsDockerAccess, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), - dependencies: [], - }) - : makePostgresServiceDocker({ - image: postgresResolution.image, - dataDir: config.postgres.dataDir, - port: config.dbPort, - platformOs: platform.os, - jwtSecret: config.jwtSecret, - jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, - identity, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), - dependencies: [], - })), + ...postgresService, enabled: true, }, ]; - if (hasPostgresInit) { - defs.push({ - ...makePostgresInitService({ - postgresDir: postgresResolution.path, - dbPort: config.dbPort, - autoExposeNewTables: config.postgres.autoExposeNewTables, - dependencies: [{ service: "postgres", condition: "healthy" }], - }), - dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, - enabled: true, - }); - } + defs.push({ + ...(postgresResolution.type === "binary" + ? makePostgresInitService({ + postgresDir: postgresResolution.path, + dbPort: config.dbPort, + jwtSecret: config.jwtSecret, + jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, + autoExposeNewTables: config.postgres.autoExposeNewTables, + dependencies: [{ service: "postgres", condition: "healthy" }], + }) + : makePostgresInitServiceDocker({ + runtime: yield* requireContainerRuntime, + dbPort: config.dbPort, + jwtSecret: config.jwtSecret, + jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, + autoExposeNewTables: config.postgres.autoExposeNewTables, + identity, + dependencies: [{ service: "postgres", condition: "healthy" }], + })), + dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, + enabled: true, + }); if (config.postgrest !== false && postgrestResolution !== false) { defs.push({ @@ -320,9 +372,10 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, - dependencies: postgresDeps, + dependencies: postgresDependencies, }) : makePostgrestServiceDocker({ + runtime: yield* requireContainerRuntime, image: postgrestResolution.image, dbHost: serviceHost, dbPort: config.dbPort, @@ -334,7 +387,7 @@ export class StackBuilder extends Context.Service< jwtSecret: config.jwtSecret, platformOs: platform.os, identity, - dependencies: postgresDeps, + dependencies: postgresDependencies, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -356,9 +409,10 @@ export class StackBuilder extends Context.Service< smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, - dependencies: postgresDeps, + dependencies: postgresDependencies, }) : makeAuthServiceDocker({ + runtime: yield* requireContainerRuntime, image: authResolution.image, dbHost: serviceHost, dbPort: config.dbPort, @@ -373,37 +427,31 @@ export class StackBuilder extends Context.Service< smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, platformOs: platform.os, identity, - dependencies: postgresDeps, + dependencies: postgresDependencies, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); } - if (config.edgeRuntime !== false && edgeRuntimeResolution !== false) { + if (config.edgeRuntime !== false) { + const edgeRuntimeImage = yield* requirePreparedDockerImage(prepared, "edge-runtime"); + const edgeRuntimeBootstrapDir = yield* prepareEdgeRuntimeBootstrap(config.runtimeRoot); defs.push({ - ...(edgeRuntimeResolution.type === "binary" - ? makeEdgeRuntimeServiceNative({ - binPath: edgeRuntimeResolution.path, - runtimeRoot: config.runtimeRoot, - port: config.edgeRuntime.port, - inspectorPort: config.edgeRuntime.inspectorPort, - policy: config.edgeRuntime.policy, - env: config.edgeRuntime.env, - dependencies: postgresDeps, - }) - : makeEdgeRuntimeServiceDocker({ - image: edgeRuntimeResolution.image, - identity, - runtimeRoot: config.runtimeRoot, - projectDir, - port: config.edgeRuntime.port, - inspectorPort: config.edgeRuntime.inspectorPort, - policy: config.edgeRuntime.policy, - env: config.edgeRuntime.env, - platformOs: platform.os, - dependencies: postgresDeps, - })), + ...makeEdgeRuntimeServiceDocker({ + runtime: yield* requireContainerRuntime, + image: edgeRuntimeImage, + identity, + runtimeRoot: config.runtimeRoot, + bootstrapDir: edgeRuntimeBootstrapDir, + projectDir, + port: config.edgeRuntime.port, + inspectorPort: config.edgeRuntime.inspectorPort, + policy: config.edgeRuntime.policy, + env: config.edgeRuntime.env, + platformOs: platform.os, + dependencies: postgresDependencies, + }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); @@ -413,6 +461,7 @@ export class StackBuilder extends Context.Service< const mailpitImage = yield* requirePreparedDockerImage(prepared, "mailpit"); defs.push({ ...makeMailpitServiceDocker({ + runtime: yield* requireContainerRuntime, image: mailpitImage, identity, webPort: config.mailpit.port, @@ -429,6 +478,7 @@ export class StackBuilder extends Context.Service< const realtimeImage = yield* requirePreparedDockerImage(prepared, "realtime"); defs.push({ ...makeRealtimeServiceDocker({ + runtime: yield* requireContainerRuntime, image: realtimeImage, port: config.realtime.port, identity, @@ -441,7 +491,7 @@ export class StackBuilder extends Context.Service< secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -452,6 +502,7 @@ export class StackBuilder extends Context.Service< const storageImage = yield* requirePreparedDockerImage(prepared, "storage"); defs.push({ ...makeStorageServiceDocker({ + runtime: yield* requireContainerRuntime, image: storageImage, port: config.storage.port, identity, @@ -468,7 +519,7 @@ export class StackBuilder extends Context.Service< config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", s3ProtocolEnabled: config.storage.s3ProtocolEnabled, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -481,6 +532,7 @@ export class StackBuilder extends Context.Service< const imgproxyImage = yield* requirePreparedDockerImage(prepared, "imgproxy"); defs.push({ ...makeImgproxyServiceDocker({ + runtime: yield* requireContainerRuntime, image: imgproxyImage, port: config.imgproxy.port, identity, @@ -497,13 +549,14 @@ export class StackBuilder extends Context.Service< const pgmetaImage = yield* requirePreparedDockerImage(prepared, "pgmeta"); defs.push({ ...makePgmetaServiceDocker({ + runtime: yield* requireContainerRuntime, image: pgmetaImage, identity, port: config.pgmeta.port, dbHost: serviceHost, dbPort: config.dbPort, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -514,6 +567,7 @@ export class StackBuilder extends Context.Service< const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); defs.push({ ...makeAnalyticsServiceDocker({ + runtime: yield* requireContainerRuntime, image: analyticsImage, identity, hostPort: config.analytics.port, @@ -522,7 +576,7 @@ export class StackBuilder extends Context.Service< dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -534,6 +588,7 @@ export class StackBuilder extends Context.Service< const vectorImage = yield* requirePreparedDockerImage(prepared, "vector"); defs.push({ ...makeVectorServiceDocker({ + runtime: yield* requireContainerRuntime, image: vectorImage, identity, serviceHost, @@ -551,6 +606,7 @@ export class StackBuilder extends Context.Service< const poolerImage = yield* requirePreparedDockerImage(prepared, "pooler"); defs.push({ ...makePoolerServiceDocker({ + runtime: yield* requireContainerRuntime, image: poolerImage, identity, hostAdminPort: config.pooler.apiPort, @@ -565,7 +621,7 @@ export class StackBuilder extends Context.Service< tenantId: config.pooler.tenantId, encryptionKey: config.pooler.encryptionKey, secretKeyBase: config.pooler.secretKeyBase, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -577,6 +633,7 @@ export class StackBuilder extends Context.Service< const studioImage = yield* requirePreparedDockerImage(prepared, "studio"); defs.push({ ...makeStudioServiceDocker({ + runtime: yield* requireContainerRuntime, image: studioImage, identity, port: config.studio.port, @@ -608,7 +665,12 @@ export class StackBuilder extends Context.Service< } const dockerContainerNames = SERVICE_NAMES.filter((service) => - defs.some((def) => def.name === service && def.command === "docker"), + defs.some( + (def) => + def.name === service && + config.runtime.mode === "docker" && + def.command === config.runtime.containerRuntime, + ), ).map((service) => dockerContainerName(service, identity.key)); const graph = yield* buildGraph(defs).pipe( @@ -626,7 +688,7 @@ export class StackBuilder extends Context.Service< cleanupTargets: { dockerContainerNames, }, - serviceProjection: publicServiceProjection(defs, hasPostgresInit), + serviceProjection: publicServiceProjection(defs), }; }), }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 84cbe83e3d..3a32510215 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -1,18 +1,25 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Deferred, Effect, FileSystem, Layer, Predicate, Scope, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; import { candidateCleanupTargets } from "./cleanup.ts"; +import { activationTargetsForService } from "./ServiceActivation.ts"; +import { SERVICE_NAMES } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; import { StackBuilder, validateResolvedConfig } from "./StackBuilder.ts"; import type { BuildResult } from "./StackBuilder.ts"; import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; import { STACK_ID_LABEL } from "./StackIdentity.ts"; import { enabledServicesForConfig, versionsForConfig } from "./StackBuilder.ts"; -import { nativePostgresNeedsDockerAccess } from "./StackBuilder.ts"; import type { AllocatedPorts } from "./PortCatalog.ts"; -import { StackPreparation } from "./StackPreparation.ts"; +import { preparationClosure, StackPreparation } from "./StackPreparation.ts"; import type { StackPreparationInput } from "./StackPreparation.ts"; +import { resolveConfig } from "./StackConfigResolver.ts"; import { dependencyTimeoutSecondsForServices, POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, @@ -47,8 +54,22 @@ const baseConfig: ResolvedStackConfig = { stackRoot: "/tmp/supabase-stack", runtimeRoot: "/tmp/supabase-runtime", projectDir: "/tmp/supabase-project", - mode: "auto", - startupMode: "eager", + runtime: { mode: "native", containerRuntime: null }, + servicePolicies: { + postgres: "eager", + postgrest: "eager", + auth: "eager", + "edge-runtime": "off", + realtime: "off", + storage: "off", + imgproxy: "off", + mailpit: "off", + pgmeta: "off", + studio: "off", + analytics: "off", + vector: "off", + pooler: "off", + }, readiness: DEFAULT_STACK_READINESS_POLICY, readinessSource: "default", jwtSecret: testJwtSecret, @@ -96,7 +117,7 @@ const baseConfig: ResolvedStackConfig = { const dockerConfig: ResolvedStackConfig = { ...baseConfig, - mode: "docker", + runtime: { mode: "docker", containerRuntime: "docker" }, }; /** @@ -119,7 +140,8 @@ const siblingManagedConfig: ResolvedStackConfig = { const edgeRuntimeConfig: ResolvedStackConfig = { ...baseConfig, - mode: "auto", + runtime: { mode: "docker", containerRuntime: "docker" }, + servicePolicies: { ...baseConfig.servicePolicies, "edge-runtime": "eager" }, edgeRuntime: { enabled: true, port: basePorts.edgeRuntimePort, @@ -171,6 +193,7 @@ function builderLayer( ) { return Layer.mergeAll( StackBuilder.layer, + NodeFileSystem.layer, StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawnerLayer)), ); } @@ -179,30 +202,28 @@ const prepareAndBuild = ( builder: typeof StackBuilder.Service, preparation: typeof StackPreparation.Service, config: ResolvedStackConfig, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const input: StackPreparationInput = { - mode: config.mode, + const shared = { services: enabledServicesForConfig(config), versions: versionsForConfig(config), }; + const input: StackPreparationInput = + config.runtime.mode === "native" + ? { ...shared, mode: "native" } + : { ...shared, mode: "docker", containerRuntime: config.runtime.containerRuntime }; const prepared = yield* preparation.prepare(input); - return yield* builder.build(config, prepared); + const fs = yield* FileSystem.FileSystem; + const scope = yield* Effect.scope; + return yield* builder + .build(config, prepared) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); }); describe("StackBuilder", () => { - it("makes native postgres reachable by docker services on every platform", () => { - expect(nativePostgresNeedsDockerAccess({ type: "binary", path: "/cache/postgres" }, true)).toBe( - true, - ); - expect( - nativePostgresNeedsDockerAccess({ type: "binary", path: "/cache/postgres" }, false), - ).toBe(false); - expect( - nativePostgresNeedsDockerAccess({ type: "docker", image: "supabase/postgres" }, true), - ).toBe(false); - }); - it.effect("builds graph with all native binaries", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -255,70 +276,6 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses docker fallback when auth binary not found", () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, baseConfig); - - expect(graph.startOrder.length).toBe(4); - - const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef).toBeDefined(); - expect(authDef?.command).toBe("docker"); - expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(authDef?.supervision).toBeDefined(); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses docker fallback when postgres binary not found", () => { - const resolver = mockBinaryResolver({ failServices: ["postgres"] }); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, baseConfig); - - // No postgres-init when postgres falls back to Docker. - expect(graph.startOrder.length).toBe(3); - - const postgresDef = graph.startOrder.find((s) => s.name === "postgres"); - expect(postgresDef).toBeDefined(); - expect(postgresDef?.command).toBe("docker"); - expect(postgresDef?.supervision).toBeDefined(); - - // postgrest falls back to postgres(healthy) dependency - const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - - const names = graph.startOrder.map((s) => s.name); - expect(names).not.toContain("postgres-init"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses docker fallback when postgrest binary not found", () => { - const resolver = mockBinaryResolver({ failServices: ["postgrest"] }); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, baseConfig); - - // All 4 services still present (postgrest falls back to Docker, not removed) - expect(graph.startOrder.length).toBe(4); - - const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef).toBeDefined(); - expect(postgrestDef?.command).toBe("docker"); - expect(postgrestDef?.supervision).toBeDefined(); - }).pipe(Effect.provide(layer)); - }); - it.effect("excludes disabled services", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -341,20 +298,24 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); - it.effect("docker mode produces Docker service defs for all services", () => { + it.effect("Docker mode consistently uses the selected container runtime", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); return Effect.gen(function* () { const builder = yield* StackBuilder; const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, dockerConfig); + const config = { + ...dockerConfig, + runtime: { mode: "docker", containerRuntime: "podman" }, + } satisfies ResolvedStackConfig; + const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, config); - expect(graph.startOrder.length).toBe(3); + expect(graph.startOrder.length).toBe(4); const names = graph.startOrder.map((s) => s.name); expect(names).toContain("postgres"); - expect(names).not.toContain("postgres-init"); + expect(names).toContain("postgres-init"); expect(names).toContain("postgrest"); expect(names).toContain("auth"); @@ -363,19 +324,93 @@ describe("StackBuilder", () => { for (const name of ["postgres", "postgrest", "auth"]) { const def = graph.startOrder.find((s) => s.name === name); expect(def).toBeDefined(); - expect(def?.command).toBe("docker"); + expect(def?.command).toBe("podman"); expect(def?.supervision).toBeDefined(); + expect(def?.supervision?.orphanCleanup).toContainEqual( + expect.objectContaining({ executable: "podman" }), + ); } // Docker container names are collected for cleanup expect(cleanupTargets.dockerContainerNames).toEqual([ - `supabase-postgres-${dockerConfig.apiPort}`, - `supabase-postgrest-${dockerConfig.apiPort}`, - `supabase-auth-${dockerConfig.apiPort}`, + `supabase-postgres-${config.apiPort}`, + `supabase-postgrest-${config.apiPort}`, + `supabase-auth-${config.apiPort}`, ]); }).pipe(Effect.provide(layer)); }); + it.effect("prepares nested PostgreSQL bind-mount parents before Docker launch", () => { + const resolver = mockBinaryResolver(); + const root = mkdtempSync(join(tmpdir(), "sup-stack-postgres-data-")); + const dataDir = join(root, "data", "postgres"); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + yield* prepareAndBuild(builder, preparation, { + ...dockerConfig, + postgres: { ...dockerConfig.postgres, dataDir }, + }); + expect(existsSync(dataDir)).toBe(true); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("prepares every public service in each startable graph closure", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const config = yield* resolveConfig( + { + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, + }, + { + ports: basePorts, + stackRoot: "/tmp/supabase-stack", + runtimeRoot: "/tmp/supabase-runtime", + runtime: { mode: "docker", containerRuntime: "docker" }, + }, + ); + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, config); + + for (const service of SERVICE_NAMES) { + const prepared = new Set( + preparationClosure(activationTargetsForService(SERVICE_NAMES, service), SERVICE_NAMES), + ); + const publicGraphServices = graph + .startOrderFor(service) + .map((definition) => definition.name) + .filter((name): name is ServiceName => + SERVICE_NAMES.some((candidate) => candidate === name), + ); + expect( + publicGraphServices.length, + `${service} must exercise a non-empty public graph closure`, + ).toBeGreaterThan(0); + const missing = publicGraphServices.filter((name) => !prepared.has(name)); + expect( + missing, + `${service} starts public services outside its preparation closure`, + ).toEqual([]); + } + }).pipe(Effect.provide(layer)); + }); + it.effect("names and labels a stack's containers by the identity it was given", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -399,7 +434,7 @@ describe("StackBuilder", () => { expect(name).not.toContain(String(managedConfig.apiPort)); } - for (const def of graph.startOrder) { + for (const def of graph.startOrder.filter((service) => service.args?.[0] === "run")) { expect(def.args).toContain(`supabase-${def.name}-id-${firstManagedId}`); // The label carries the whole identity, so the containers stay findable // by it even if the names are ever built differently. @@ -415,7 +450,7 @@ describe("StackBuilder", () => { instanceId: "../bad:id", }).pipe(Effect.flip); - expect(error._tag).toBe("StackBuildError"); + expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); expect(error.reason).toBe("invalid_config"); }); }); @@ -457,14 +492,14 @@ describe("StackBuilder", () => { `supabase-postgrest-${dockerConfig.apiPort}`, `supabase-auth-${dockerConfig.apiPort}`, ]); - for (const def of graph.startOrder) { + for (const def of graph.startOrder.filter((service) => service.args?.[0] === "run")) { expect(def.args).toContain(`supabase-${def.name}-${dockerConfig.apiPort}`); expect(def.args?.join(" ")).not.toContain(STACK_ID_LABEL); } }).pipe(Effect.provide(layer)); }); - it.effect("docker mode wires auth directly to postgres readiness", () => { + it.effect("docker consumers wait for database initialization", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -474,27 +509,13 @@ describe("StackBuilder", () => { const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); + expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); expect(authDef?.dependencyTimeoutSeconds).toBe( - dependencyTimeoutSecondsForServices(["postgres"]), + dependencyTimeoutSecondsForServices(["postgres"]) + POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, ); }).pipe(Effect.provide(layer)); }); - it.effect("docker mode has no postgres-init service for Docker postgres", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); - - const names = graph.startOrder.map((s) => s.name); - expect(names).not.toContain("postgres-init"); - }).pipe(Effect.provide(layer)); - }); - it.effect("docker mode wires dependencies correctly", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -505,41 +526,17 @@ describe("StackBuilder", () => { const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); + expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - // postgrest depends on postgres(healthy) — no postgres-init in Docker mode const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses docker-backed edge-runtime even when a native binary is available", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild( - builder, - preparation, - edgeRuntimeConfig, - ); - - const edgeRuntimeDef = graph.startOrder.find((service) => service.name === "edge-runtime"); - expect(edgeRuntimeDef).toBeDefined(); - expect(edgeRuntimeDef?.command).toBe("docker"); - expect(edgeRuntimeDef?.dependencies).toEqual([ + expect(postgrestDef?.dependencies).toEqual([ { service: "postgres-init", condition: "completed" }, ]); - expect(cleanupTargets.dockerContainerNames).toContain( - `supabase-edge-runtime-${edgeRuntimeConfig.apiPort}`, - ); }).pipe(Effect.provide(layer)); }); - it.effect("uses docker-backed edge-runtime when the binary is unavailable", () => { - const resolver = mockBinaryResolver({ failServices: ["edge-runtime"] }); + it.effect("uses Docker for edge-runtime and its dependencies in Docker mode", () => { + const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); return Effect.gen(function* () { @@ -562,38 +559,4 @@ describe("StackBuilder", () => { ); }).pipe(Effect.provide(layer)); }); - - it.effect("falls back to the next registry for docker-only services", () => { - const resolver = mockBinaryResolver(); - const spawnerLayer = mockSequenceSpawner([ - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 0 }, - ]); - const layer = builderLayer(resolver, spawnerLayer); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, { - ...dockerConfig, - realtime: { - port: 3010, - version: DEFAULT_VERSIONS.realtime, - tenantId: "realtime-dev", - encryptionKey: "supabaserealtime", - secretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - maxHeaderLength: 4096, - }, - }); - - const realtimeDef = graph.startOrder.find((service) => service.name === "realtime"); - expect(realtimeDef?.args).toContain(`supabase/realtime:v${DEFAULT_VERSIONS.realtime}`); - expect(realtimeDef?.args).not.toContain( - `public.ecr.aws/supabase/realtime:v${DEFAULT_VERSIONS.realtime}`, - ); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 25ba352931..13c6868700 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -1,9 +1,13 @@ import { Schema } from "effect"; import type { ResolvedFunctionsBundle } from "./functions.ts"; import type { ResolvedPorts } from "./PortCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; -type StackMode = "native" | "auto" | "docker"; -type StackStartupMode = "eager" | "lazy"; +import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; + +export type StackMode = "native" | "docker"; +export type ServicePolicy = "off" | "lazy" | "eager"; +export type ServicePolicyManifest = Readonly>; export type ReadinessPolicy = | { readonly mode: "finite"; readonly timeoutMs: number } @@ -174,8 +178,8 @@ export interface StackConfig { readonly runtimeRoot?: string; readonly projectDir?: string; readonly mode?: StackMode; - /** Start all services immediately, or defer proxied services until first use. */ - readonly startupMode?: StackStartupMode; + /** Per-service resource policy. `off` excludes a service from the graph. */ + readonly servicePolicies?: Partial>; /** Stack-wide readiness policy. Per-call ReadyOptions take precedence. */ readonly readiness?: ReadinessPolicy; readonly jwtSecret?: string; @@ -303,8 +307,9 @@ export interface ResolvedStackConfig { readonly stackRoot: string; readonly runtimeRoot: string; readonly projectDir: string; - readonly mode: StackMode; - readonly startupMode: StackStartupMode; + /** Concrete execution mode and, for containers, the selected executable. */ + readonly runtime: StackRuntimeSelection; + readonly servicePolicies: ServicePolicyManifest; readonly readiness: ReadinessPolicy; /** Whether readiness came from the package default or an explicit stack policy. */ readonly readinessSource: "default" | "configured"; diff --git a/packages/stack/src/StackConfigResolver.policy.unit.test.ts b/packages/stack/src/StackConfigResolver.policy.unit.test.ts new file mode 100644 index 0000000000..7c23d5c71b --- /dev/null +++ b/packages/stack/src/StackConfigResolver.policy.unit.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Cause, Effect, Exit, FileSystem } from "effect"; +import { systemError } from "effect/PlatformError"; +import { + resolveConfig as resolveConfigEffect, + type ResolveConfigOptions, +} from "./StackConfigResolver.ts"; +import { StackBuildError } from "./errors.ts"; +import type { PortSet } from "./PortCatalog.ts"; + +const testPorts: PortSet = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + realtimePort: 40_005, + storagePort: 40_006, + imgproxyPort: 40_007, + mailpitPort: 40_008, + mailpitSmtpPort: 40_009, + mailpitPop3Port: 40_010, + pgmetaPort: 40_011, + studioPort: 40_012, + analyticsPort: 40_013, + poolerPort: 40_014, + poolerApiPort: 40_015, +}; + +const resolveConfig = ( + config?: Parameters[0], + options?: Partial, +) => + Effect.runPromise( + resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts }).pipe( + Effect.provide(NodeFileSystem.layer), + ), + ); + +describe("resolved service preparation policies", () => { + it("maps temporary-root filesystem failures to StackBuildError", async () => { + const exit = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const failingFs = { + ...fs, + makeTempDirectory: () => + Effect.fail( + systemError({ + _tag: "PermissionDenied", + module: "test", + method: "makeTempDirectory", + }), + ), + }; + return yield* resolveConfigEffect(undefined, { ports: testPorts }).pipe( + Effect.provideService(FileSystem.FileSystem, failingFs), + Effect.exit, + ); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.findErrorOption(exit.cause)).toMatchObject({ + _tag: "Some", + value: { _tag: "StackBuildError" }, + }); + } + }); + + it("applies explicit policies and catalog defaults while keeping Postgres eager", async () => { + const config = await resolveConfig({ + servicePolicies: { postgrest: "eager", mailpit: "eager" }, + mailpit: {}, + stackRoot: "/tmp/stack-policy-test", + runtimeRoot: "/tmp/runtime-policy-test", + }); + + expect(config.servicePolicies.postgres).toBe("eager"); + expect(config.servicePolicies.postgrest).toBe("eager"); + expect(config.servicePolicies.auth).toBe("lazy"); + expect(config.servicePolicies.mailpit).toBe("eager"); + }); + + it("rejects an unsupported lazy policy before port allocation", async () => { + await expect(resolveConfig({ servicePolicies: { postgres: "lazy" } })).rejects.toBeInstanceOf( + StackBuildError, + ); + }); + + it("rejects disabling postgres through the service policy manifest", async () => { + await expect(resolveConfig({ servicePolicies: { postgres: "off" } })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); + + it("resolves explicitly disabled core services to false without reserving ports", async () => { + const config = await resolveConfig({ servicePolicies: { postgrest: "off" } }); + expect(config.postgrest).toBe(false); + expect(config.servicePolicies.postgrest).toBe("off"); + }); + + it("rejects a preparation policy for a service that is not configured", async () => { + await expect(resolveConfig({ servicePolicies: { realtime: "eager" } })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); + + it("rejects an eager service whose required public dependency is lazy before allocating ports", async () => { + await expect( + resolveConfig({ + analytics: {}, + vector: {}, + servicePolicies: { analytics: "lazy", vector: "eager" }, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); +}); diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 9aa29921a9..e35ef8a224 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -1,8 +1,11 @@ -import { mkdtempSync } from "node:fs"; import { join } from "node:path"; -import { Effect, Schema } from "effect"; -import { StackBuildError, toStackError } from "./errors.ts"; -import { resolvedFunctionsBundleSchemaForProject } from "./functions.ts"; +import { Effect, Exit, FileSystem, Record, Schema } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { StackBuildError } from "./errors.ts"; +import { + resolvedFunctionsBundleSchemaForProject, + type ResolvedFunctionsBundle, +} from "./functions.ts"; import { defaultJwtSecret, defaultPublishableKey, @@ -10,14 +13,9 @@ import { generateJwt, } from "./JwtGenerator.ts"; import { defaultCacheRoot, shortTempPrefixRoot } from "./paths.ts"; -import { - allocatePortSet, - type PortReservationRequest, - type PortAllocationError, - type PortSelectionOptions, -} from "./PortAllocator.ts"; +import { type PortReservationRequest } from "./PortAllocator.ts"; import { PORT_CATALOG, type PortField, type PortSet, type ResolvedPorts } from "./PortCatalog.ts"; -import { portFieldsForConfigInput, serviceEnabledForConfig } from "./ServicePorts.ts"; +import { portFieldsForConfigInput } from "./ServicePorts.ts"; import { INSTANCE_ID_PATTERN, InstanceIdSchema, resolveReadinessPolicy } from "./StackConfig.ts"; import type { AnalyticsConfig, @@ -42,22 +40,29 @@ import type { ResolvedStorageConfig, ResolvedStudioConfig, ResolvedVectorConfig, + ServicePolicy, + ServicePolicyManifest, StackConfig, StorageConfig, StudioConfig, VectorConfig, } from "./StackConfig.ts"; -import { DEFAULT_VERSIONS } from "./ServiceCatalog.ts"; +import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; +import { + DEFAULT_SERVICE_POLICIES, + DEFAULT_VERSIONS, + SERVICE_CATALOG, + SERVICE_NAMES, + serviceMetadata, +} from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; export interface ResolveConfigOptions { + /** Ports selected by the caller-owned lease. Resolution never allocates ports. */ + readonly ports: PortSet; readonly stackRoot?: string; readonly runtimeRoot?: string; - readonly preferredPorts?: PortSet; - readonly reservedPorts?: ReadonlySet; - readonly portAllocator?: ( - requests: ReadonlyArray, - options: PortSelectionOptions, - ) => Effect.Effect; + readonly runtime?: StackRuntimeSelection; } interface ResolvedRoots { @@ -67,37 +72,68 @@ interface ResolvedRoots { readonly autoManagedPaths: ReadonlyArray; } -const makeTempRoot = (prefix: string) => mkdtempSync(join(shortTempPrefixRoot(), prefix)); - -const resolveRoots = (config: StackConfig, opts: ResolveConfigOptions): ResolvedRoots => { - const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); - const autoManagedPaths: string[] = []; - - const stackRoot = - opts.stackRoot ?? - config.stackRoot ?? - (() => { - const dir = makeTempRoot("sb-stack-"); - autoManagedPaths.push(dir); - return dir; - })(); - - const runtimeRoot = - opts.runtimeRoot ?? - config.runtimeRoot ?? - (() => { - const dir = makeTempRoot("sb-run-"); - autoManagedPaths.push(dir); - return dir; - })(); - - return { - cacheRoot, - stackRoot, - runtimeRoot, - autoManagedPaths, - }; -}; +const cleanupAutoManagedPaths = ( + paths: ReadonlyArray, +): Effect.Effect => + Effect.uninterruptible( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.forEach( + paths, + (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignoreCause), + { discard: true }, + ); + }), + ); + +const tempRootError = (prefix: string, cause: PlatformError): StackBuildError => + new StackBuildError({ + detail: `Failed to create temporary ${prefix} directory`, + cause, + }); + +const makeTempRoot = ( + prefix: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs + .makeTempDirectory({ directory: shortTempPrefixRoot(), prefix }) + .pipe(Effect.mapError((cause) => tempRootError(prefix, cause))); + }); + +const resolveRoots = ( + config: StackConfig, + opts: ResolveConfigOptions, +): Effect.Effect => + Effect.gen(function* () { + const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); + const autoManagedPaths: string[] = []; + const roots = yield* Effect.gen(function* () { + const makeTrackedTempRoot = (prefix: string) => + Effect.uninterruptibleMask((restore) => + restore(makeTempRoot(prefix)).pipe( + Effect.tap((dir) => Effect.sync(() => autoManagedPaths.push(dir))), + ), + ); + const stackRoot = + opts.stackRoot ?? config.stackRoot ?? (yield* makeTrackedTempRoot("sb-stack-")); + const runtimeRoot = + opts.runtimeRoot ?? config.runtimeRoot ?? (yield* makeTrackedTempRoot("sb-run-")); + + return { + cacheRoot, + stackRoot, + runtimeRoot, + autoManagedPaths, + }; + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : cleanupAutoManagedPaths(autoManagedPaths), + ), + ); + return roots; + }); const resolveDataDir = ( explicitDir: string | undefined, @@ -105,32 +141,39 @@ const resolveDataDir = ( suffix: string, ): string => explicitDir ?? join(stackRoot, "data", suffix); -const requiredPort = (ports: PortSet, field: PortField): number => { +const requiredPort = (ports: PortSet, field: PortField): Effect.Effect => { const port = ports[field]; if (port === undefined) { - throw new StackBuildError({ - detail: `Missing resolved port for active field ${field}`, - reason: "invalid_config", - }); + return Effect.fail( + new StackBuildError({ + detail: `Missing resolved port for active field ${field}`, + reason: "invalid_config", + }), + ); } - return port; + return Effect.succeed(port); }; function resolvePostgrestConfig( input: PostgrestConfig | undefined, raw: PostgrestConfig | false | undefined, ports: PortSet, -): ResolvedPostgrestConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { + return Effect.all({ port: requiredPort(ports, "postgrestPort"), adminPort: requiredPort(ports, "postgrestAdminPort"), - schemas: cfg.schemas ?? ["public", "graphql_public"], - extraSearchPath: cfg.extraSearchPath ?? ["public", "extensions"], - maxRows: cfg.maxRows ?? 1000, - version: cfg.version ?? DEFAULT_VERSIONS.postgrest, - }; + }).pipe( + Effect.map(({ port, adminPort }) => ({ + port, + adminPort, + schemas: cfg.schemas ?? ["public", "graphql_public"], + extraSearchPath: cfg.extraSearchPath ?? ["public", "extensions"], + maxRows: cfg.maxRows ?? 1000, + version: cfg.version ?? DEFAULT_VERSIONS.postgrest, + })), + ); } function resolveAuthConfig( @@ -138,143 +181,169 @@ function resolveAuthConfig( raw: AuthConfig | false | undefined, ports: PortSet, apiPort: number, -): ResolvedAuthConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "authPort"), - siteUrl: cfg.siteUrl ?? "http://localhost:3000", - jwtExpiry: cfg.jwtExpiry ?? 3600, - externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, - version: cfg.version ?? DEFAULT_VERSIONS.auth, - }; + return requiredPort(ports, "authPort").pipe( + Effect.map((port) => ({ + port, + siteUrl: cfg.siteUrl ?? "http://localhost:3000", + jwtExpiry: cfg.jwtExpiry ?? 3600, + externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, + version: cfg.version ?? DEFAULT_VERSIONS.auth, + })), + ); } function resolveRealtimeConfig( input: RealtimeConfig | undefined, raw: RealtimeConfig | false | undefined, ports: PortSet, -): ResolvedRealtimeConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "realtimePort"), - version: cfg.version ?? DEFAULT_VERSIONS.realtime, - tenantId: cfg.tenantId ?? "realtime-dev", - encryptionKey: cfg.encryptionKey ?? "supabaserealtime", - secretKeyBase: - cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - maxHeaderLength: cfg.maxHeaderLength ?? 4096, - }; + return requiredPort(ports, "realtimePort").pipe( + Effect.map((port) => ({ + port, + version: cfg.version ?? DEFAULT_VERSIONS.realtime, + tenantId: cfg.tenantId ?? "realtime-dev", + encryptionKey: cfg.encryptionKey ?? "supabaserealtime", + secretKeyBase: + cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + maxHeaderLength: cfg.maxHeaderLength ?? 4096, + })), + ); } function resolveEdgeRuntimeConfig( input: EdgeRuntimeConfig | undefined, raw: EdgeRuntimeConfig | false | undefined, ports: PortSet, -): ResolvedEdgeRuntimeConfig | false { - if (raw === false || raw?.enabled === false) return false; +): Effect.Effect { + if (raw === false || raw?.enabled === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - enabled: cfg.enabled ?? true, + return Effect.all({ port: requiredPort(ports, "edgeRuntimePort"), inspectorPort: requiredPort(ports, "edgeRuntimeInspectorPort"), - policy: cfg.policy ?? "per_worker", - version: cfg.version ?? DEFAULT_VERSIONS["edge-runtime"], - env: cfg.env ?? {}, - }; + }).pipe( + Effect.map(({ port, inspectorPort }) => ({ + enabled: cfg.enabled ?? true, + port, + inspectorPort, + policy: cfg.policy ?? "per_worker", + version: cfg.version ?? DEFAULT_VERSIONS["edge-runtime"], + env: cfg.env ?? {}, + })), + ); } -async function resolveFunctionsConfig(config: StackConfig, projectDir: string) { +function resolveFunctionsConfig( + config: StackConfig, + projectDir: string, +): Effect.Effect { if (config.functions === undefined || config.functions === false) { - return false; - } - try { - return await Schema.decodeUnknownPromise(resolvedFunctionsBundleSchemaForProject(projectDir))( - config.functions, - ); - } catch (cause) { - throw new StackBuildError({ - detail: "Invalid Edge Functions bundle", - cause, - reason: "invalid_config", - }); + return Effect.succeed(false); } + return Schema.decodeUnknownEffect(resolvedFunctionsBundleSchemaForProject(projectDir))( + config.functions, + ).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Invalid Edge Functions bundle", + cause, + reason: "invalid_config", + }), + ), + ); } -function resolveInstanceId(instanceId: string | undefined): string | undefined { - if (instanceId === undefined) { - return undefined; - } - try { - return Schema.decodeUnknownSync(InstanceIdSchema)(instanceId); - } catch (cause) { - throw new StackBuildError({ - detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, - cause, - reason: "invalid_config", - }); - } -} +const resolveInstanceId = ( + instanceId: string | undefined, +): Effect.Effect => + instanceId === undefined + ? Effect.succeed(undefined) + : Schema.decodeUnknownEffect(InstanceIdSchema)(instanceId).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, + cause, + reason: "invalid_config", + }), + ), + ); function resolveStorageConfig( input: StorageConfig | undefined, raw: StorageConfig | false | undefined, ports: PortSet, opts: ResolveConfigOptions, -): ResolvedStorageConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "storagePort"), - version: cfg.version ?? DEFAULT_VERSIONS.storage, - dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), - fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", - s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, - }; + return requiredPort(ports, "storagePort").pipe( + Effect.map((port) => ({ + port, + version: cfg.version ?? DEFAULT_VERSIONS.storage, + dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), + fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", + s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, + })), + ); } function resolveImgproxyConfig( input: ImgproxyConfig | undefined, raw: ImgproxyConfig | false | undefined, ports: PortSet, -): ResolvedImgproxyConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "imgproxyPort"), - version: cfg.version ?? DEFAULT_VERSIONS.imgproxy, - }; + return requiredPort(ports, "imgproxyPort").pipe( + Effect.map((port) => ({ + port, + version: cfg.version ?? DEFAULT_VERSIONS.imgproxy, + })), + ); } function resolveMailpitConfig( input: MailpitConfig | undefined, raw: MailpitConfig | false | undefined, ports: PortSet, -): ResolvedMailpitConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { + return Effect.all({ port: requiredPort(ports, "mailpitPort"), smtpPort: requiredPort(ports, "mailpitSmtpPort"), pop3Port: requiredPort(ports, "mailpitPop3Port"), - version: cfg.version ?? DEFAULT_VERSIONS.mailpit, - adminEmail: cfg.adminEmail ?? "admin@email.com", - senderName: cfg.senderName ?? "Admin", - }; + }).pipe( + Effect.map(({ port, smtpPort, pop3Port }) => ({ + port, + smtpPort, + pop3Port, + version: cfg.version ?? DEFAULT_VERSIONS.mailpit, + adminEmail: cfg.adminEmail ?? "admin@email.com", + senderName: cfg.senderName ?? "Admin", + })), + ); } function resolvePgmetaConfig( input: PgmetaConfig | undefined, raw: PgmetaConfig | false | undefined, ports: PortSet, -): ResolvedPgmetaConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "pgmetaPort"), - version: cfg.version ?? DEFAULT_VERSIONS.pgmeta, - }; + return requiredPort(ports, "pgmetaPort").pipe( + Effect.map((port) => ({ + port, + version: cfg.version ?? DEFAULT_VERSIONS.pgmeta, + })), + ); } function resolveStudioConfig( @@ -282,61 +351,70 @@ function resolveStudioConfig( raw: StudioConfig | false | undefined, ports: PortSet, apiPort: number, -): ResolvedStudioConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "studioPort"), - version: cfg.version ?? DEFAULT_VERSIONS.studio, - apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, - }; + return requiredPort(ports, "studioPort").pipe( + Effect.map((port) => ({ + port, + version: cfg.version ?? DEFAULT_VERSIONS.studio, + apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, + })), + ); } function resolveAnalyticsConfig( input: AnalyticsConfig | undefined, raw: AnalyticsConfig | false | undefined, ports: PortSet, -): ResolvedAnalyticsConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { - port: requiredPort(ports, "analyticsPort"), - version: cfg.version ?? DEFAULT_VERSIONS.analytics, - backend: cfg.backend ?? "postgres", - apiKey: cfg.apiKey ?? "api-key", - }; + return requiredPort(ports, "analyticsPort").pipe( + Effect.map((port) => ({ + port, + version: cfg.version ?? DEFAULT_VERSIONS.analytics, + backend: cfg.backend ?? "postgres", + apiKey: cfg.apiKey ?? "api-key", + })), + ); } function resolveVectorConfig( input: VectorConfig | undefined, raw: VectorConfig | false | undefined, -): ResolvedVectorConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { + return Effect.succeed({ version: cfg.version ?? DEFAULT_VERSIONS.vector, - }; + }); } function resolvePoolerConfig( input: PoolerConfig | undefined, raw: PoolerConfig | false | undefined, ports: PortSet, -): ResolvedPoolerConfig | false { - if (raw === false) return false; +): Effect.Effect { + if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return { + return Effect.all({ port: requiredPort(ports, "poolerPort"), apiPort: requiredPort(ports, "poolerApiPort"), - mode: cfg.mode ?? "transaction", - version: cfg.version ?? DEFAULT_VERSIONS.pooler, - tenantId: cfg.tenantId ?? "pooler-dev", - encryptionKey: cfg.encryptionKey ?? "12345678901234567890123456789032", - secretKeyBase: - cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - defaultPoolSize: cfg.defaultPoolSize ?? 20, - maxClientConn: cfg.maxClientConn ?? 100, - }; + }).pipe( + Effect.map(({ port, apiPort }) => ({ + port, + apiPort, + mode: cfg.mode ?? "transaction", + version: cfg.version ?? DEFAULT_VERSIONS.pooler, + tenantId: cfg.tenantId ?? "pooler-dev", + encryptionKey: cfg.encryptionKey ?? "12345678901234567890123456789032", + secretKeyBase: + cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + defaultPoolSize: cfg.defaultPoolSize ?? 20, + maxClientConn: cfg.maxClientConn ?? 100, + })), + ); } const enabledServiceConfig = ( @@ -344,164 +422,407 @@ const enabledServiceConfig = ( config: Config | false | undefined, ): Config | undefined => (enabled && config !== false ? config : undefined); -export async function resolveConfig( - input?: StackConfig, - opts: ResolveConfigOptions = {}, -): Promise { - const config = input ?? {}; - const projectDir = config.projectDir ?? process.cwd(); - const instanceId = resolveInstanceId(config.instanceId); - const functions = await resolveFunctionsConfig(config, projectDir); - const resolvedMode = config.mode ?? "auto"; - const roots = resolveRoots(config, opts); - const postgresInput = config.postgres ?? {}; - const postgrestInput = config.postgrest !== false ? (config.postgrest ?? undefined) : undefined; - const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; - const edgeRuntimeEnabled = serviceEnabledForConfig(config, "edge-runtime"); - const realtimeEnabled = serviceEnabledForConfig(config, "realtime"); - const storageEnabled = serviceEnabledForConfig(config, "storage"); - const imgproxyEnabled = serviceEnabledForConfig(config, "imgproxy"); - const mailpitEnabled = serviceEnabledForConfig(config, "mailpit"); - const pgmetaEnabled = serviceEnabledForConfig(config, "pgmeta"); - const studioEnabled = serviceEnabledForConfig(config, "studio"); - const analyticsEnabled = serviceEnabledForConfig(config, "analytics"); - const vectorEnabled = serviceEnabledForConfig(config, "vector"); - const poolerEnabled = serviceEnabledForConfig(config, "pooler"); - const edgeRuntimeInput = enabledServiceConfig(edgeRuntimeEnabled, config.edgeRuntime); - const realtimeInput = enabledServiceConfig(realtimeEnabled, config.realtime); - const storageInput = enabledServiceConfig(storageEnabled, config.storage); - const imgproxyInput = enabledServiceConfig(imgproxyEnabled, config.imgproxy); - const mailpitInput = enabledServiceConfig(mailpitEnabled, config.mailpit); - const pgmetaInput = enabledServiceConfig(pgmetaEnabled, config.pgmeta); - const studioInput = enabledServiceConfig(studioEnabled, config.studio); - const analyticsInput = enabledServiceConfig(analyticsEnabled, config.analytics); - const vectorInput = enabledServiceConfig(vectorEnabled, config.vector); - const poolerInput = enabledServiceConfig(poolerEnabled, config.pooler); - - const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); - - const explicitPortForField = (field: PortField): number | undefined => { - switch (field) { - case "apiPort": - return config.port; - case "dbPort": - return postgresInput.port; - case "authPort": - return authInput?.port; - case "edgeRuntimePort": - return edgeRuntimeInput?.port; - case "edgeRuntimeInspectorPort": - return edgeRuntimeInput?.inspectorPort; - case "realtimePort": - return realtimeInput?.port; - case "storagePort": - return storageInput?.port; - case "imgproxyPort": - return imgproxyInput?.port; - case "mailpitPort": - return mailpitInput?.port; - case "mailpitSmtpPort": - return mailpitInput?.smtpPort; - case "mailpitPop3Port": - return mailpitInput?.pop3Port; - case "pgmetaPort": - return pgmetaInput?.port; - case "studioPort": - return studioInput?.port; - case "analyticsPort": - return analyticsInput?.port; - case "poolerPort": - return poolerInput?.port; - case "poolerApiPort": - return poolerInput?.apiPort; - case "postgrestPort": - case "postgrestAdminPort": - return undefined; +const rawServiceEnabled = (config: StackConfig, service: ServiceName): boolean => { + switch (service) { + case "postgres": + return true; + case "postgrest": + return config.postgrest !== false; + case "auth": + return config.auth !== false; + case "edge-runtime": + return ( + ((config.mode ?? "native") !== "native" || config.edgeRuntime !== undefined) && + config.edgeRuntime !== false && + (config.edgeRuntime?.enabled ?? true) !== false + ); + case "realtime": + return config.realtime !== undefined && config.realtime !== false; + case "storage": + return config.storage !== undefined && config.storage !== false; + case "imgproxy": + return config.imgproxy !== undefined && config.imgproxy !== false; + case "mailpit": + return config.mailpit !== undefined && config.mailpit !== false; + case "pgmeta": + return config.pgmeta !== undefined && config.pgmeta !== false; + case "studio": + return config.studio !== undefined && config.studio !== false; + case "analytics": + return config.analytics !== undefined && config.analytics !== false; + case "vector": + return config.vector !== undefined && config.vector !== false; + case "pooler": + return config.pooler !== undefined && config.pooler !== false; + } +}; + +const preparationPolicyRank: Readonly> = { + off: 0, + lazy: 1, + eager: 2, +}; + +/** + * Resolve policy declarations before roots, ports, or config-dependent effects + * are acquired. This keeps unsupported policies a pure user/configuration error. + */ +const resolveServicePolicies = ( + config: StackConfig, +): Effect.Effect => + Effect.gen(function* () { + const policies: Record = Record.map(SERVICE_CATALOG, () => "off"); + const requestedPolicies = config.servicePolicies ?? {}; + for (const service of SERVICE_NAMES) { + const requested = requestedPolicies[service]; + if (service === "postgres" && requested !== undefined && requested !== "eager") { + return yield* Effect.fail( + new StackBuildError({ + detail: "postgres supports only the eager service preparation policy", + reason: "invalid_config", + }), + ); + } + + const enabled = rawServiceEnabled(config, service); + if (!enabled && requested !== undefined && requested !== "off") { + return yield* Effect.fail( + new StackBuildError({ + detail: `${service} cannot use the ${requested} service preparation policy because the service is not configured`, + reason: "invalid_config", + }), + ); + } + if (!enabled || requested === "off") { + policies[service] = "off"; + continue; + } + + const policy: Exclude = + requested === undefined ? DEFAULT_SERVICE_POLICIES[service] : requested; + if (!serviceMetadata(service).preparation.supported.includes(policy)) { + return yield* Effect.fail( + new StackBuildError({ + detail: `${service} does not support the ${policy} service preparation policy`, + reason: "invalid_config", + }), + ); + } + policies[service] = policy; } - }; - - const unorderedRequests: ReadonlyArray = portFieldsForConfigInput( - config, - ).map((field) => { - const explicit = explicitPortForField(field); - if (explicit !== undefined) { - return { field, selection: { kind: "exact", port: explicit } }; + + let promoted = true; + while (promoted) { + promoted = false; + for (const service of SERVICE_NAMES) { + const policy = policies[service]; + if (policy === "off") continue; + for (const dependency of serviceMetadata(service).activation.activates) { + const dependencyPolicy = policies[dependency]; + if ( + dependencyPolicy === "off" || + preparationPolicyRank[dependencyPolicy] <= preparationPolicyRank[policy] + ) { + continue; + } + if (requestedPolicies[service] !== undefined) { + return yield* Effect.fail( + new StackBuildError({ + detail: `${dependency} uses the ${dependencyPolicy} preparation policy but requires ${service} to be at least ${dependencyPolicy}`, + reason: "invalid_config", + }), + ); + } + policies[service] = dependencyPolicy; + promoted = true; + } + } } - const preferred = opts.preferredPorts?.[field] ?? PORT_CATALOG[field].preferred; - return preferred === undefined - ? { field, selection: { kind: "automatic" } } - : { field, selection: { kind: "automatic", preferred } }; + return policies; }); - const requests: ReadonlyArray = [ - ...unorderedRequests.filter((request) => request.selection.kind === "exact"), - ...unorderedRequests.filter((request) => request.selection.kind === "automatic"), - ]; - - const ports = await Effect.runPromise( - (opts.portAllocator ?? allocatePortSet)(requests, { reserved: opts.reservedPorts }), - ).catch((error: unknown) => { - throw toStackError(error); + +export interface PortRequestOptions { + readonly preferredPorts?: PortSet; + readonly runtime?: StackRuntimeSelection; +} + +/** + * Validate the allocation-relevant parts of a stack configuration and return + * exact requests before automatic requests. The helper is allocation-free; + * callers reserve the returned requests and pass the resulting ports into + * `resolveConfig`. + */ +export const portRequestsForConfig = ( + input: StackConfig = {}, + options: PortRequestOptions = {}, +): Effect.Effect, StackBuildError> => + Effect.gen(function* () { + if ( + input.mode !== undefined && + options.runtime !== undefined && + input.mode !== options.runtime.mode + ) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Selected ${options.runtime.mode} runtime does not match requested ${input.mode} mode`, + reason: "invalid_config", + }), + ); + } + const mode = options.runtime?.mode ?? input.mode ?? "native"; + const config: StackConfig = { ...input, mode }; + if (mode === "docker" && options.runtime?.containerRuntime == null) { + return yield* Effect.fail( + new StackBuildError({ + detail: "Docker mode requires a selected Docker or Podman runtime", + reason: "invalid_config", + }), + ); + } + + // Deliberately first: unsupported policies and invalid explicit ports must + // fail before a caller acquires any OS resource. + yield* resolveServicePolicies(config); + const postgresInput = config.postgres ?? {}; + const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; + const edgeRuntimeInput = config.edgeRuntime !== false ? config.edgeRuntime : undefined; + const realtimeInput = config.realtime !== false ? (config.realtime ?? undefined) : undefined; + const storageInput = config.storage !== false ? (config.storage ?? undefined) : undefined; + const imgproxyInput = config.imgproxy !== false ? (config.imgproxy ?? undefined) : undefined; + const mailpitInput = config.mailpit !== false ? (config.mailpit ?? undefined) : undefined; + const pgmetaInput = config.pgmeta !== false ? (config.pgmeta ?? undefined) : undefined; + const studioInput = config.studio !== false ? (config.studio ?? undefined) : undefined; + const analyticsInput = config.analytics !== false ? (config.analytics ?? undefined) : undefined; + const poolerInput = config.pooler !== false ? (config.pooler ?? undefined) : undefined; + const explicitPortForField = (field: PortField): number | undefined => { + switch (field) { + case "apiPort": + return config.port; + case "dbPort": + return postgresInput.port; + case "authPort": + return authInput?.port; + case "edgeRuntimePort": + return edgeRuntimeInput?.port; + case "edgeRuntimeInspectorPort": + return edgeRuntimeInput?.inspectorPort; + case "realtimePort": + return realtimeInput?.port; + case "storagePort": + return storageInput?.port; + case "imgproxyPort": + return imgproxyInput?.port; + case "mailpitPort": + return mailpitInput?.port; + case "mailpitSmtpPort": + return mailpitInput?.smtpPort; + case "mailpitPop3Port": + return mailpitInput?.pop3Port; + case "pgmetaPort": + return pgmetaInput?.port; + case "studioPort": + return studioInput?.port; + case "analyticsPort": + return analyticsInput?.port; + case "poolerPort": + return poolerInput?.port; + case "poolerApiPort": + return poolerInput?.apiPort; + case "postgrestPort": + case "postgrestAdminPort": + return undefined; + } + }; + const activeFields = portFieldsForConfigInput(config); + for (const field of activeFields) { + const explicit = explicitPortForField(field); + if ( + explicit !== undefined && + (!Number.isInteger(explicit) || explicit < 1 || explicit > 65_535) + ) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Invalid port for ${field}: expected an integer between 1 and 65535`, + reason: "invalid_config", + }), + ); + } + } + const unorderedRequests = activeFields.map((field) => { + const explicit = explicitPortForField(field); + if (explicit !== undefined) { + return { field, selection: { kind: "exact", port: explicit } } as const; + } + const preferred = options.preferredPorts?.[field] ?? PORT_CATALOG[field].preferred; + return preferred === undefined + ? ({ field, selection: { kind: "automatic" } } as const) + : ({ field, selection: { kind: "automatic", preferred } } as const); + }); + return [ + ...unorderedRequests.filter((request) => request.selection.kind === "exact"), + ...unorderedRequests.filter((request) => request.selection.kind === "automatic"), + ]; }); - const jwtSecret = config.jwtSecret ?? defaultJwtSecret; - const anonJwt = generateJwt(jwtSecret, "anon"); - const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); - const apiPort = requiredPort(ports, "apiPort"); - const dbPort = requiredPort(ports, "dbPort"); - const resolvedPorts: ResolvedPorts = { ...ports, apiPort, dbPort }; - - return { - instanceId, - cacheRoot: roots.cacheRoot, - stackRoot: roots.stackRoot, - runtimeRoot: roots.runtimeRoot, - projectDir, - mode: resolvedMode, - startupMode: config.startupMode ?? "eager", - readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), - readinessSource: config.readiness === undefined ? "default" : "configured", - jwtSecret, - ports: resolvedPorts, - apiPort, - dbPort, - publishableKey: config.publishableKey ?? defaultPublishableKey, - secretKey: config.secretKey ?? defaultSecretKey, - functions, - autoManagedPaths: roots.autoManagedPaths, - anonJwt, - serviceRoleJwt, - postgres: { - port: dbPort, - dataDir: postgresDataDir, - version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, - autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, - }, - postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), - auth: resolveAuthConfig(authInput, config.auth, ports, apiPort), - edgeRuntime: edgeRuntimeEnabled - ? resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) - : false, - realtime: realtimeEnabled - ? resolveRealtimeConfig(realtimeInput, config.realtime, ports) - : false, - storage: storageEnabled - ? resolveStorageConfig(storageInput, config.storage, ports, { - ...opts, - stackRoot: roots.stackRoot, - }) - : false, - imgproxy: imgproxyEnabled - ? resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) - : false, - mailpit: mailpitEnabled ? resolveMailpitConfig(mailpitInput, config.mailpit, ports) : false, - pgmeta: pgmetaEnabled ? resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) : false, - studio: studioEnabled ? resolveStudioConfig(studioInput, config.studio, ports, apiPort) : false, - analytics: analyticsEnabled - ? resolveAnalyticsConfig(analyticsInput, config.analytics, ports) - : false, - vector: vectorEnabled ? resolveVectorConfig(vectorInput, config.vector) : false, - pooler: poolerEnabled ? resolvePoolerConfig(poolerInput, config.pooler, ports) : false, - }; +export function resolveConfig( + input: StackConfig | undefined, + opts: ResolveConfigOptions, +): Effect.Effect { + return Effect.suspend(() => { + let roots: ResolvedRoots | undefined; + const cleanup = () => + roots === undefined ? Effect.void : cleanupAutoManagedPaths(roots.autoManagedPaths); + + return Effect.gen(function* () { + const inputConfig = input ?? {}; + const runtime: StackRuntimeSelection = opts.runtime ?? { + mode: "native", + containerRuntime: null, + }; + const config: StackConfig = { ...inputConfig, mode: runtime.mode }; + // Deliberately first: unsupported policies must not create roots or reserve ports. + const servicePolicies = yield* resolveServicePolicies(config); + for (const field of portFieldsForConfigInput(config)) { + if (opts.ports[field] === undefined) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Missing resolved port for active field ${field}`, + reason: "invalid_config", + }), + ); + } + } + const projectDir = config.projectDir ?? process.cwd(); + const instanceId = yield* resolveInstanceId(config.instanceId); + const functions = yield* resolveFunctionsConfig(config, projectDir); + const edgeRuntimeEnabled = servicePolicies["edge-runtime"] !== "off"; + if (functions !== false && !edgeRuntimeEnabled) { + return yield* Effect.fail( + new StackBuildError({ + detail: "Edge Functions require Edge Runtime to be enabled", + reason: "invalid_config", + }), + ); + } + roots = yield* resolveRoots(config, opts); + const postgresInput = config.postgres ?? {}; + const postgrestInput = + servicePolicies.postgrest !== "off" && config.postgrest !== false + ? (config.postgrest ?? undefined) + : undefined; + const authInput = + servicePolicies.auth !== "off" && config.auth !== false + ? (config.auth ?? undefined) + : undefined; + const realtimeEnabled = servicePolicies.realtime !== "off"; + const storageEnabled = servicePolicies.storage !== "off"; + const imgproxyEnabled = servicePolicies.imgproxy !== "off"; + const mailpitEnabled = servicePolicies.mailpit !== "off"; + const pgmetaEnabled = servicePolicies.pgmeta !== "off"; + const studioEnabled = servicePolicies.studio !== "off"; + const analyticsEnabled = servicePolicies.analytics !== "off"; + const vectorEnabled = servicePolicies.vector !== "off"; + const poolerEnabled = servicePolicies.pooler !== "off"; + const edgeRuntimeInput = enabledServiceConfig(edgeRuntimeEnabled, config.edgeRuntime); + const realtimeInput = enabledServiceConfig(realtimeEnabled, config.realtime); + const storageInput = enabledServiceConfig(storageEnabled, config.storage); + const imgproxyInput = enabledServiceConfig(imgproxyEnabled, config.imgproxy); + const mailpitInput = enabledServiceConfig(mailpitEnabled, config.mailpit); + const pgmetaInput = enabledServiceConfig(pgmetaEnabled, config.pgmeta); + const studioInput = enabledServiceConfig(studioEnabled, config.studio); + const analyticsInput = enabledServiceConfig(analyticsEnabled, config.analytics); + const vectorInput = enabledServiceConfig(vectorEnabled, config.vector); + const poolerInput = enabledServiceConfig(poolerEnabled, config.pooler); + + const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); + + // Port selection is owned by the caller. Resolve the provided lease result only. + const ports = opts.ports; + + const jwtSecret = config.jwtSecret ?? defaultJwtSecret; + const anonJwt = generateJwt(jwtSecret, "anon"); + const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); + const apiPort = yield* requiredPort(ports, "apiPort"); + const dbPort = yield* requiredPort(ports, "dbPort"); + const resolvedPorts: ResolvedPorts = { ...ports, apiPort, dbPort }; + + return { + instanceId, + cacheRoot: roots.cacheRoot, + stackRoot: roots.stackRoot, + runtimeRoot: roots.runtimeRoot, + projectDir, + runtime, + servicePolicies, + readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), + readinessSource: + config.readiness === undefined ? ("default" as const) : ("configured" as const), + jwtSecret, + ports: resolvedPorts, + apiPort, + dbPort, + publishableKey: config.publishableKey ?? defaultPublishableKey, + secretKey: config.secretKey ?? defaultSecretKey, + functions, + autoManagedPaths: roots.autoManagedPaths, + anonJwt, + serviceRoleJwt, + postgres: { + port: dbPort, + dataDir: postgresDataDir, + version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, + }, + postgrest: yield* resolvePostgrestConfig( + postgrestInput, + servicePolicies.postgrest === "off" ? false : config.postgrest, + ports, + ), + auth: yield* resolveAuthConfig( + authInput, + servicePolicies.auth === "off" ? false : config.auth, + ports, + apiPort, + ), + edgeRuntime: edgeRuntimeEnabled + ? yield* resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) + : false, + realtime: realtimeEnabled + ? yield* resolveRealtimeConfig(realtimeInput, config.realtime, ports) + : false, + storage: storageEnabled + ? yield* resolveStorageConfig(storageInput, config.storage, ports, { + ...opts, + stackRoot: roots.stackRoot, + }) + : false, + imgproxy: imgproxyEnabled + ? yield* resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) + : false, + mailpit: mailpitEnabled + ? yield* resolveMailpitConfig(mailpitInput, config.mailpit, ports) + : false, + pgmeta: pgmetaEnabled + ? yield* resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) + : false, + studio: studioEnabled + ? yield* resolveStudioConfig(studioInput, config.studio, ports, apiPort) + : false, + analytics: analyticsEnabled + ? yield* resolveAnalyticsConfig(analyticsInput, config.analytics, ports) + : false, + vector: vectorEnabled ? yield* resolveVectorConfig(vectorInput, config.vector) : false, + pooler: poolerEnabled + ? yield* resolvePoolerConfig(poolerInput, config.pooler, ports) + : false, + }; + }).pipe( + Effect.catchDefect((cause) => + cause instanceof StackBuildError ? Effect.fail(cause) : Effect.die(cause), + ), + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : cleanup())), + ); + }); } export type DaemonConfigInput = Omit & { diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 50761ed59e..9e91795f56 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -1,13 +1,21 @@ -import { Cause, Data, Effect, Exit, Layer, Queue, Context, Stream } from "effect"; +import { Context, Data, Deferred, Duration, Effect, Layer, Queue, Schedule, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; -import type { ChecksumMismatchError } from "./errors.ts"; -import { DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; -import { isDockerOnlyService } from "./ServiceCatalog.ts"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; +import type { + BinaryHostCompatibilityError, + BinaryManifestError, + BinaryRuntimeError, + ChecksumMismatchError, + DownloadError, +} from "./errors.ts"; +import { BinaryNotFoundError, DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; +import { isDockerOnlyService, requiredPreparationDependencies } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES, - dockerImageCandidatesForService, + dockerImageForService, + normalizeServiceVersions, type ServiceName, type VersionManifest, } from "./versions.ts"; @@ -20,12 +28,27 @@ export type ServiceResolution = | { readonly type: "binary"; readonly path: string } | { readonly type: "docker"; readonly image: string }; -export interface StackPreparationInput { +interface StackPreparationOptions { readonly versions?: Partial; readonly services?: ReadonlyArray; - readonly mode?: "native" | "auto" | "docker"; + readonly enabledServices?: ReadonlyArray; } +export type StackPreparationInput = StackPreparationOptions & + ( + | { readonly mode: "native"; readonly containerRuntime?: never } + | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } + ); + +export type StackPreparationError = + | BinaryNotFoundError + | DownloadError + | ChecksumMismatchError + | BinaryManifestError + | BinaryRuntimeError + | BinaryHostCompatibilityError + | DockerPullError; + export class ServiceDownloadStarted extends Data.TaggedClass("ServiceDownloadStarted")<{ readonly service: ServiceName; }> {} @@ -34,16 +57,15 @@ export class ServiceDownloadFinished extends Data.TaggedClass("ServiceDownloadFi readonly service: ServiceName; }> {} -class PreparationCompleted extends Data.TaggedClass("PreparationCompleted")<{ +export class PreparationCompleted extends Data.TaggedClass("PreparationCompleted")<{ readonly artifacts: PreparedStackArtifacts; }> {} -type StackPreparationEvent = +export type StackPreparationEvent = | ServiceDownloadStarted | ServiceDownloadFinished | PreparationCompleted; -const DOCKER_PULL_RETRY_DELAYS_MS = [500] as const; const RETRYABLE_PULL_PATTERNS = [ /toomanyrequests/i, /rate exceeded/i, @@ -56,104 +78,112 @@ const RETRYABLE_PULL_PATTERNS = [ /i\/o timeout/i, ] as const; -interface PullAttemptFailure { - readonly image: string; - readonly attempt: number; - readonly message: string; +class PullAttemptError extends Error { + constructor( + readonly detail: string, + readonly daemonDown: boolean, + ) { + super(detail); + this.name = "PullAttemptError"; + } } +const pullRetrySchedule = Schedule.exponential(Duration.seconds(1)).pipe( + Schedule.upTo({ times: 5 }), +); + const resolveDockerImageForService = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + runtime: ContainerRuntime, service: ServiceName, version: string, callbacks?: { readonly onDownloadStart?: Effect.Effect; }, ): Effect.Effect => - pullImage(spawner, dockerImageCandidatesForService(service, version), callbacks); - -export const prepareAssetsWithDependencies = ( + pullImage(spawner, runtime, dockerImageForService(service, version), callbacks); + +export const preparationClosure = ( + services: ReadonlyArray, + enabledServices?: ReadonlyArray, +): ReadonlyArray => { + const enabled = enabledServices === undefined ? undefined : new Set(enabledServices); + const closure = new Set(); + const add = (service: ServiceName): void => { + if (enabled !== undefined && !enabled.has(service)) return; + if (closure.has(service)) return; + closure.add(service); + for (const dependency of requiredPreparationDependencies(service)) add(dependency); + }; + for (const service of services) add(service); + return [...closure]; +}; + +const selectedServices = (input: StackPreparationInput): ReadonlyArray => { + const defaults = + input.mode === "docker" + ? SERVICE_NAMES + : SERVICE_NAMES.filter((service) => !isDockerOnlyService(service)); + return preparationClosure(input.services ?? defaults, input.enabledServices); +}; + +const versionsForInput = (input: StackPreparationInput): VersionManifest => ({ + ...DEFAULT_VERSIONS, + ...normalizeServiceVersions(input.versions ?? {}), +}); + +const plannedResolution = ( resolver: BinaryResolver["Service"], - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - input?: StackPreparationInput, - publishEvent?: (event: StackPreparationEvent) => Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const versions = { ...DEFAULT_VERSIONS, ...input?.versions }; - const services: ReadonlyArray = input?.services ?? SERVICE_NAMES; - const mode = input?.mode ?? "auto"; - - type Entry = readonly [ServiceName, ServiceResolution]; - - const resolveService = ( - service: ServiceName, - ): Effect.Effect => { - let isDownloading = false; - const markDownloadStart = () => - Effect.sync(() => { - isDownloading = true; - }).pipe( - Effect.andThen(publishEvent?.(new ServiceDownloadStarted({ service })) ?? Effect.void), - ); - const markDownloadFinished = () => - Effect.suspend(() => - isDownloading - ? (publishEvent?.(new ServiceDownloadFinished({ service })) ?? Effect.void) - : Effect.void, - ); - - if (mode === "docker") { - return resolveDockerImageForService(spawner, service, versions[service], { - onDownloadStart: markDownloadStart(), - }).pipe( - Effect.map((image): Entry => [service, { type: "docker", image }]), - Effect.ensuring(markDownloadFinished()), - ); - } - - if (isDockerOnlyService(service)) { - return resolveDockerImageForService(spawner, service, versions[service], { - onDownloadStart: markDownloadStart(), - }).pipe( - Effect.map((image): Entry => [service, { type: "docker", image }]), - Effect.ensuring(markDownloadFinished()), - ); - } - - return resolveServiceWithMetadata( - resolver, - spawner, - service, - versions[service], - markDownloadStart(), - ).pipe( - Effect.map((resolution): Entry => [service, resolution]), - Effect.ensuring(markDownloadFinished()), - ); - }; - - const results = yield* Effect.all(services.map(resolveService), { - concurrency: "unbounded", + service: ServiceName, + version: string, + mode: "native" | "docker", +): Effect.Effect => { + if (mode === "docker") { + return Effect.succeed({ + type: "docker", + image: dockerImageForService(service, version), }); + } + if (isDockerOnlyService(service)) { + return Effect.fail(new BinaryNotFoundError({ service, platform: "native" })); + } + return resolver + .plan({ service, version }) + .pipe(Effect.map((path): ServiceResolution => ({ type: "binary", path }))); +}; - const resolutions: Partial> = {}; - for (const [service, resolution] of results) { - resolutions[service] = resolution; - } - const artifacts = { resolutions } satisfies PreparedStackArtifacts; - yield* publishEvent?.(new PreparationCompleted({ artifacts })) ?? Effect.void; - return artifacts; +const planAssetsWithDependencies = ( + resolver: BinaryResolver["Service"], + input: StackPreparationInput, + versions = versionsForInput(input), +): Effect.Effect => + Effect.gen(function* () { + const services = selectedServices(input); + const results = yield* Effect.all( + services.map((service) => + plannedResolution(resolver, service, versions[service], input.mode).pipe( + Effect.map((resolution) => [service, resolution] as const), + ), + ), + { concurrency: "unbounded" }, + ); + return { + resolutions: Object.fromEntries(results), + } satisfies PreparedStackArtifacts; }); export class StackPreparation extends Context.Service< StackPreparation, { + readonly plan: ( + input: StackPreparationInput, + ) => Effect.Effect; readonly prepare: ( - input?: StackPreparationInput, - ) => Effect.Effect; + input: StackPreparationInput, + ) => Effect.Effect; readonly prepareEvents: ( - input?: StackPreparationInput, - ) => Stream.Stream; + input: StackPreparationInput, + ) => Stream.Stream; } >()("stack/StackPreparation") { static layer: Layer.Layer< @@ -165,15 +195,111 @@ export class StackPreparation extends Context.Service< Effect.gen(function* () { const resolver = yield* BinaryResolver; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Effect.scope; + const inFlight = new Map< + string, + Deferred.Deferred + >(); + + const materialize = ( + service: ServiceName, + resolution: ServiceResolution, + version: string, + input: StackPreparationInput, + publishEvent?: (event: StackPreparationEvent) => Effect.Effect, + ): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + let downloadStarted = false; + const markDownloadStart = () => + Effect.sync(() => { + downloadStarted = true; + }).pipe( + Effect.andThen( + publishEvent?.(new ServiceDownloadStarted({ service })) ?? Effect.void, + ), + ); + const markDownloadFinished = () => + Effect.suspend(() => + downloadStarted + ? (publishEvent?.(new ServiceDownloadFinished({ service })) ?? Effect.void) + : Effect.void, + ); + const key = JSON.stringify({ + service, + resolution, + containerRuntime: input.mode === "docker" ? input.containerRuntime : null, + }); + const existing = inFlight.get(key); + if (existing !== undefined) return restore(Deferred.await(existing)); + const deferred = Deferred.makeUnsafe(); + inFlight.set(key, deferred); + const effect: Effect.Effect = + resolution.type === "docker" + ? input.mode === "docker" + ? resolveDockerImageForService( + spawner, + input.containerRuntime, + service, + version, + { + onDownloadStart: markDownloadStart(), + }, + ).pipe(Effect.map((image): ServiceResolution => ({ type: "docker", image }))) + : Effect.die("Native preparation planned a Docker resolution") + : resolver + .resolveWithMetadata( + { service, version }, + { + onDownloadStart: markDownloadStart(), + }, + ) + .pipe(Effect.map(({ path }): ServiceResolution => ({ type: "binary", path }))); + const coordinated = effect.pipe( + Effect.matchCauseEffect({ + onSuccess: (value) => + Effect.andThen(markDownloadFinished(), Deferred.succeed(deferred, value)), + onFailure: (cause) => Deferred.failCause(deferred, cause), + }), + Effect.ensuring(Effect.sync(() => inFlight.delete(key))), + ); + return Effect.gen(function* () { + yield* Effect.forkIn(coordinated, scope, { startImmediately: true }); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); + + const prepareWithEvents = ( + input: StackPreparationInput, + publishEvent?: (event: StackPreparationEvent) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const versions = versionsForInput(input); + const planned = yield* planAssetsWithDependencies(resolver, input, versions); + const entries = yield* Effect.all( + selectedServices(input).map((service) => { + const resolution = planned.resolutions[service]; + if (resolution === undefined) return Effect.die(`Missing plan for ${service}`); + return materialize(service, resolution, versions[service], input, publishEvent).pipe( + Effect.map((resolved) => [service, resolved] as const), + ); + }), + { concurrency: 4 }, + ); + const artifacts = { + resolutions: Object.fromEntries(entries), + } satisfies PreparedStackArtifacts; + yield* publishEvent?.(new PreparationCompleted({ artifacts })) ?? Effect.void; + return artifacts; + }); return { - prepare: (input?: StackPreparationInput) => - prepareAssetsWithDependencies(resolver, spawner, input), - prepareEvents: (input?: StackPreparationInput) => - Stream.callback((queue) => - prepareAssetsWithDependencies(resolver, spawner, input, (event) => - Queue.offer(queue, event), - ).pipe( + plan: (input: StackPreparationInput) => planAssetsWithDependencies(resolver, input), + prepare: (input: StackPreparationInput) => prepareWithEvents(input), + prepareEvents: (input: StackPreparationInput) => + Stream.callback((queue) => + prepareWithEvents(input, (event) => Queue.offer(queue, event)).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), onSuccess: () => Queue.end(queue), @@ -188,155 +314,78 @@ export class StackPreparation extends Context.Service< const pullImage = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - images: ReadonlyArray, + runtime: ContainerRuntime, + image: string, callbacks?: { readonly onDownloadStart?: Effect.Effect; }, ): Effect.Effect => Effect.gen(function* () { - const cachedImage = yield* findLocalDockerImage(spawner, images); - if (cachedImage !== undefined) { - return cachedImage; + if (yield* hasLocalDockerImage(spawner, runtime, image)) { + return image; } yield* callbacks?.onDownloadStart ?? Effect.void; - const failures: PullAttemptFailure[] = []; - let spawnFailed = false; - - for (const image of images) { - for ( - let attemptIndex = 0; - attemptIndex <= DOCKER_PULL_RETRY_DELAYS_MS.length; - attemptIndex += 1 - ) { - const attempt = attemptIndex + 1; - const result = yield* Effect.exit(runPullCommand(spawner, image)); - if (Exit.isSuccess(result)) { - // A successful spawn proves the runtime is usable; an earlier - // transient spawn failure must not taint the final classification. - spawnFailed = false; - if (result.value.exitCode === 0) { - return image; - } - - const message = - result.value.stderr.length > 0 - ? result.value.stderr - : `docker pull exited with code ${result.value.exitCode}`; - failures.push({ image, attempt, message }); - - if (!shouldRetryPull(message) || attemptIndex === DOCKER_PULL_RETRY_DELAYS_MS.length) { - break; - } - } else { - // A failed effect (rather than a non-zero exit) means the container - // runtime could not be spawned at all — a local Docker setup - // problem, not a registry failure. - spawnFailed = true; - const cause = Cause.squash(result.cause); - const message = cause instanceof Error ? cause.message : String(cause); - failures.push({ image, attempt, message }); - if (!shouldRetryPull(message) || attemptIndex === DOCKER_PULL_RETRY_DELAYS_MS.length) { - break; - } - } - - const retryDelay = DOCKER_PULL_RETRY_DELAYS_MS[attemptIndex]; - if (retryDelay === undefined) { - break; - } - yield* Effect.sleep(`${retryDelay} millis`); - } - } - - const detail = failures - .map((failure) => `${failure.image} attempt ${failure.attempt}: ${failure.message}`) - .join("; "); - - return yield* Effect.fail( - new DockerPullError({ - image: images[0] ?? "unknown", - detail: `Failed to pull Docker image from all registries. ${detail}`, - cause: new Error(detail), - daemonDown: - spawnFailed || failures.some((failure) => isDockerDaemonDownMessage(failure.message)), + return yield* runPullCommand(spawner, runtime, image).pipe( + Effect.retry({ + while: (error) => shouldRetryPull(error.detail), + schedule: pullRetrySchedule, }), + Effect.as(image), + Effect.catch((failure) => + Effect.fail( + new DockerPullError({ + image, + detail: `Failed to pull canonical Docker image. ${failure.detail}`, + cause: new Error(failure.detail), + daemonDown: failure.daemonDown, + }), + ), + ), ); }); -const resolveServiceWithMetadata = ( - resolver: BinaryResolver["Service"], - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - service: ServiceName, - version: string, - onDownloadStart: Effect.Effect, -): Effect.Effect => - resolver.resolveWithMetadata({ service, version }, { onDownloadStart }).pipe( - Effect.map(({ path }): ServiceResolution => ({ type: "binary", path })), - Effect.catchTag("BinaryNotFoundError", () => - resolveDockerImageForService(spawner, service, version, { - onDownloadStart, - }).pipe( - Effect.map((image): ServiceResolution => ({ - type: "docker", - image, - })), - ), - ), - Effect.catchTag("DownloadError", () => - resolveDockerImageForService(spawner, service, version, { - onDownloadStart, - }).pipe( - Effect.map((image): ServiceResolution => ({ - type: "docker", - image, - })), - ), - ), - ); - const runPullCommand = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + runtime: ContainerRuntime, image: string, -): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, Error> => +): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, PullAttemptError> => Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make("docker", ["pull", image])); + const child = yield* spawner.spawn(ChildProcess.make(runtime, ["pull", image])); const [stderr, exitCode] = yield* Effect.all( [collectStreamAsString(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, ); - return { + const result = { exitCode, stderr: stderr.trim(), }; + if (result.exitCode !== 0) { + const detail = + result.stderr.length > 0 + ? result.stderr + : `${runtime} pull exited with code ${result.exitCode}`; + return yield* Effect.fail(new PullAttemptError(detail, isDockerDaemonDownMessage(detail))); + } + return result; }).pipe( Effect.scoped, - Effect.catchTag("PlatformError", (error) => Effect.fail(new Error(String(error)))), + Effect.catchTag("PlatformError", (error) => + Effect.fail(new PullAttemptError(String(error), true)), + ), ); const hasLocalDockerImage = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + runtime: ContainerRuntime, image: string, ): Effect.Effect => - spawner.exitCode(ChildProcess.make("docker", ["image", "inspect", image])).pipe( + spawner.exitCode(ChildProcess.make(runtime, ["image", "inspect", image])).pipe( Effect.map((exitCode) => exitCode === 0), Effect.catchTag("PlatformError", () => Effect.succeed(false)), ); -const findLocalDockerImage = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - images: ReadonlyArray, -): Effect.Effect => - Effect.gen(function* () { - for (const image of images) { - if (yield* hasLocalDockerImage(spawner, image)) { - return image; - } - } - return undefined; - }); - const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => Stream.runFold( stream, diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 2d05640881..9a5da207e0 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -2,9 +2,13 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; +import { createStack as createStackCore, type ResolveConfigEffect } from "./createStack.ts"; +import { toStackHandle, type StackHandle } from "./stackHandle.ts"; +import { toStackError } from "./errors.ts"; import { prefetch as prefetchEffect, + type PrefetchEffectOptions, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; @@ -12,18 +16,41 @@ import { defaultCacheRoot } from "./paths.ts"; import { platformFactory } from "./platform-bun.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; +import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; + +const resolveConfigEffectForPlatform: ResolveConfigEffect = (config, options) => + resolveConfigEffect(config, options); export async function createStack(config?: StackConfig): Promise { - return createStackCore(config, platformFactory); + const runtime = await Effect.runPromise( + selectStackRuntime(config?.mode).pipe(Effect.provide(BunServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); + const handle = await Effect.runPromise( + createStackCore(config, platformFactory, runtime, resolveConfigEffectForPlatform).pipe( + Effect.provide(BunServices.layer), + ), + ); + return toStackHandle(handle); } export async function prefetch(options?: PrefetchOptions): Promise { + const runtime = await Effect.runPromise( + selectStackRuntime(options?.mode).pipe(Effect.provide(BunServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const resolvedOptions: PrefetchEffectOptions = + runtime.mode === "native" + ? { ...options, mode: "native" } + : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( - prefetchEffect(options).pipe( + prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), Effect.provide(BunServices.layer), ), diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts index db5db4e596..0c51d512fd 100644 --- a/packages/stack/src/cleanup.ts +++ b/packages/stack/src/cleanup.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; -import { existsSync, rmSync } from "node:fs"; -import { Duration, Effect } from "effect"; +import { Data, Duration, Effect, FileSystem, Schedule } from "effect"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; import type { ResolvedStackConfig } from "./StackConfig.ts"; @@ -20,12 +20,15 @@ export const candidateCleanupTargets = (config: ResolvedStackConfig): CleanupTar * Force-remove Docker containers by name. Best-effort safety net — * silently ignores containers that don't exist or are already removed. */ -export const dockerForceRemove = (containerNames: ReadonlyArray): Effect.Effect => +export const dockerForceRemove = ( + runtime: ContainerRuntime, + containerNames: ReadonlyArray, +): Effect.Effect => Effect.forEach( containerNames, (name) => Effect.callback((resume) => { - const child = execFile("docker", ["rm", "-f", name], { timeout: 5_000 }, () => + const child = execFile(runtime, ["rm", "-f", name], { timeout: 5_000 }, () => resume(Effect.void), ); return Effect.sync(() => child.kill()); @@ -33,57 +36,58 @@ export const dockerForceRemove = (containerNames: ReadonlyArray): Effect { concurrency: 4, discard: true }, ); -export function cleanupAutoManagedPaths(config: ResolvedStackConfig): void { - if (config.autoManagedPaths.length === 0) { - return; - } +class CleanupPending extends Data.TaggedError("CleanupPending")<{}> {} - for (const dir of config.autoManagedPaths) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // Best-effort — temp dir will be cleaned by OS eventually. - } - } - - try { - rmSync(`${config.postgres.dataDir}_pg_hba_docker.conf`, { force: true }); - } catch {} -} - -const cleanupAutoManagedPathsWithRetry = (config: ResolvedStackConfig): Effect.Effect => +const cleanupAutoManagedPathsWithRetry = ( + paths: ReadonlyArray, +): Effect.Effect => Effect.gen(function* () { - if (config.autoManagedPaths.length === 0) { + if (paths.length === 0) { return; } - const cleanupTargets = [ - ...config.autoManagedPaths.map((path) => ({ path, recursive: true as const })), - { path: `${config.postgres.dataDir}_pg_hba_docker.conf`, recursive: false as const }, - ]; - - for (let attempt = 0; attempt < 80; attempt++) { - yield* Effect.sync(() => { - for (const target of cleanupTargets) { - try { - rmSync(target.path, { recursive: target.recursive, force: true }); - } catch {} - } - }); - - if (cleanupTargets.every((target) => !existsSync(target.path))) { - return; - } - - yield* Effect.sleep(Duration.millis(250)); - } + const fs = yield* FileSystem.FileSystem; + const attempt = Effect.gen(function* () { + yield* Effect.forEach( + paths, + (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignore), + { concurrency: 4, discard: true }, + ); + const remaining = yield* Effect.forEach( + paths, + (path) => + fs.exists(path).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(true))), + { concurrency: 4 }, + ); + if (remaining.some(Boolean)) yield* Effect.fail(new CleanupPending()); + }).pipe(Effect.uninterruptible); + const retries = Effect.sleep(Duration.millis(250)).pipe( + Effect.andThen( + attempt.pipe( + Effect.retry( + Schedule.recurs(78).pipe(Schedule.addDelay(() => Effect.succeed(Duration.millis(250)))), + ), + ), + ), + // Cleanup callers mask their surrounding teardown transaction. Re-enable + // interruption only for the bounded waits after the guaranteed first pass. + Effect.interruptible, + ); + yield* attempt.pipe( + Effect.catchTag("CleanupPending", () => retries), + Effect.catchTag("CleanupPending", () => Effect.void), + ); }); +export const cleanupAutoManagedPaths = ( + paths: ReadonlyArray, +): Effect.Effect => cleanupAutoManagedPathsWithRetry(paths); + export const cleanupLocalStackResources = (opts: { readonly stop: () => Effect.Effect; readonly cleanupTargets: CleanupTargets; readonly config: ResolvedStackConfig; -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { // Best-effort graceful shutdown — stop() may fail if services already // exited or the scope is partially closed. Make the stop path @@ -94,6 +98,11 @@ export const cleanupLocalStackResources = (opts: { // Safety net: force-remove any Docker containers that survived // signal-based shutdown. On macOS, killing the `docker run` client // may not stop the container. - yield* dockerForceRemove(opts.cleanupTargets.dockerContainerNames); - yield* cleanupAutoManagedPathsWithRetry(opts.config); + if (opts.config.runtime.mode === "docker") { + yield* dockerForceRemove( + opts.config.runtime.containerRuntime, + opts.cleanupTargets.dockerContainerNames, + ); + } + yield* cleanupAutoManagedPathsWithRetry(opts.config.autoManagedPaths); }); diff --git a/packages/stack/src/cleanup.unit.test.ts b/packages/stack/src/cleanup.unit.test.ts new file mode 100644 index 0000000000..0828e5ad21 --- /dev/null +++ b/packages/stack/src/cleanup.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, FileSystem } from "effect"; +import { systemError } from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; +import { cleanupAutoManagedPaths } from "./cleanup.ts"; + +describe("automatic managed-path cleanup", () => { + it.effect("can be interrupted while masked teardown waits to retry a busy path", () => + Effect.gen(function* () { + const attempted = yield* Deferred.make(); + const finished = yield* Deferred.make(); + const fs = FileSystem.makeNoop({ + remove: () => Effect.void, + exists: () => Deferred.succeed(attempted, undefined).pipe(Effect.as(true)), + }); + const cleanup = cleanupAutoManagedPaths(["/owned"]).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + // Production teardown masks the surrounding cleanup transaction. The + // bounded retry wait must still remain interruptible within that mask. + Effect.uninterruptible, + Effect.ensuring(Deferred.succeed(finished, undefined)), + ); + const fiber = yield* cleanup.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(attempted); + const interruption = yield* Fiber.interrupt(fiber).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + const interruptedBeforeNextRetry = yield* Deferred.isDone(finished); + + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(interruption); + expect(interruptedBeforeNextRetry).toBe(true); + }), + ); + + it.effect("retries when path existence cannot be determined", () => + Effect.gen(function* () { + const checked = yield* Deferred.make(); + let removalAttempts = 0; + const fs = FileSystem.makeNoop({ + remove: () => + Effect.sync(() => { + removalAttempts += 1; + }), + exists: () => + Deferred.succeed(checked, undefined).pipe( + Effect.andThen( + Effect.fail( + systemError({ + _tag: "PermissionDenied", + module: "test", + method: "exists", + }), + ), + ), + ), + }); + const fiber = yield* cleanupAutoManagedPaths(["/owned"]).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(checked); + yield* TestClock.adjust("250 millis"); + yield* Effect.yieldNow; + expect(removalAttempts).toBe(2); + yield* Fiber.interrupt(fiber); + }), + ); +}); diff --git a/packages/stack/src/createStack.integration.test.ts b/packages/stack/src/createStack.integration.test.ts index 59110eeadd..5cff352cfe 100644 --- a/packages/stack/src/createStack.integration.test.ts +++ b/packages/stack/src/createStack.integration.test.ts @@ -1,98 +1,262 @@ +import { createServer } from "node:net"; +import { existsSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; -import { Effect } from "effect"; -import { createStack, type StackHandle } from "./createStack.ts"; -import { reservePortSet } from "./PortAllocator.ts"; +import { NodeFileSystem, NodeServices } from "@effect/platform-node"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, Option } from "effect"; +import { systemError } from "effect/PlatformError"; +import { + createStack, + type ForegroundStackHandle, + type PlatformFactory, + type ResolveConfigEffect, +} from "./createStack.ts"; import { platformFactory } from "./platform-node.ts"; +import { resolveConfig, resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; +import type { PortSet } from "./PortCatalog.ts"; +import { toStackHandle } from "./stackHandle.ts"; -const handles: StackHandle[] = []; - -const isAddressInUse = (error: unknown, depth = 0): boolean => { - if (depth > 4 || !(error instanceof Error)) return false; - if ("code" in error && Reflect.get(error, "code") === "EADDRINUSE") return true; - const cause: unknown = error.cause; - if (typeof cause === "object" && cause !== null && "code" in cause) { - if (Reflect.get(cause, "code") === "EADDRINUSE") return true; - } - return isAddressInUse(cause, depth + 1); +const handles: ForegroundStackHandle[] = []; +const testPorts: PortSet = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + edgeRuntimePort: 40_005, + edgeRuntimeInspectorPort: 40_006, + realtimePort: 40_007, + storagePort: 40_008, + imgproxyPort: 40_009, + mailpitPort: 40_010, + mailpitSmtpPort: 40_011, + mailpitPop3Port: 40_012, + pgmetaPort: 40_013, + studioPort: 40_014, + analyticsPort: 40_015, + poolerPort: 40_016, + poolerApiPort: 40_017, }; -const freshPortPair = async (): Promise => - Effect.runPromise( - Effect.scoped( - Effect.acquireRelease( - reservePortSet([ - { field: "apiPort", selection: { kind: "automatic" } }, - { field: "dbPort", selection: { kind: "automatic" } }, - ]), - (lease) => lease.releaseAll, - ).pipe( - Effect.map((lease) => { - const apiPort = lease.ports.apiPort; - const dbPort = lease.ports.dbPort; - if (apiPort === undefined || dbPort === undefined) { - throw new Error("Ephemeral port reservation returned an incomplete pair"); - } - return [apiPort, dbPort] as const; - }), - ), - ), - ); - -/** Transfer a fresh exact pair into public createStack across a bounded bind handoff retry. */ -const createStackWithFreshPorts = async ( - config: Parameters[0], - platform: Parameters[1], -): Promise>> => { - for (let attempt = 0; attempt < 3; attempt += 1) { - const [apiPort, dbPort] = await freshPortPair(); - try { - return await createStack( - { - ...config, - port: apiPort, - postgres: { ...config?.postgres, port: dbPort }, - }, - platform, - ); - } catch (error) { - if (!isAddressInUse(error) || attempt === 2) throw error; - } - } - throw new Error("Direct stack bind handoff exhausted retries"); -}; - -afterEach(async () => { - await Promise.all(handles.splice(0).map((handle) => handle.dispose())); +afterEach(() => { + const owned = handles.splice(0); + return Effect.runPromise(Effect.forEach(owned, (handle) => handle.dispose(), { discard: true })); }); describe("direct createStack port ownership", () => { + it("retries an automatic API port when the platform bind reports EADDRINUSE as a defect", async () => { + let attempts = 0; + const bindError = Object.assign(new Error("address already in use"), { code: "EADDRINUSE" }); + const collidingPlatformFactory: PlatformFactory = (options) => { + attempts += 1; + const platform = platformFactory(options); + return attempts < 3 + ? Layer.mergeAll(platform, Layer.effectDiscard(Effect.die(bindError))) + : platform; + }; + + const stack = await Effect.runPromise( + createStack( + { mode: "native", postgrest: false, auth: false }, + collidingPlatformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), + ); + handles.push(stack); + + expect(attempts).toBe(3); + }); + it("allocates only active service fields without managed state", async () => { - const stack = await createStackWithFreshPorts( - { - mode: "native", - startupMode: "lazy", - postgrest: false, - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - platformFactory, + const stack = await Effect.runPromise( + createStack( + { + mode: "native", + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + platformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), ); handles.push(stack); expect(stack.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(stack.dbUrl).toMatch(/127\.0\.0\.1:\d+/); - const activeServices = new Set((await stack.getStatus()).map((state) => state.name)); + const activeServices = new Set( + (await Effect.runPromise(stack.getStatus())).map((state) => state.name), + ); expect(activeServices).not.toContain("studio"); expect(activeServices).not.toContain("analytics"); expect(activeServices).not.toContain("pooler"); }); + + it("isolates resolver-owned roots across repeated evaluations", async () => { + const createdPaths: string[] = []; + let failNextRoot = false; + const result = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const trackingFs = { + ...fs, + makeTempDirectory: (options: Parameters[0]) => + Effect.suspend(() => + failNextRoot + ? Effect.fail( + systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "makeTempDirectory", + description: "injected failure", + }), + ) + : fs + .makeTempDirectory(options) + .pipe(Effect.tap((path) => Effect.sync(() => createdPaths.push(path)))), + ), + }; + const resolver = resolveConfigEffect({ mode: "native" }, { ports: testPorts }); + const firstConfig = yield* resolver.pipe( + Effect.provideService(FileSystem.FileSystem, trackingFs), + ); + const firstPaths = [...firstConfig.autoManagedPaths]; + failNextRoot = true; + const secondExit = yield* resolver.pipe( + Effect.provideService(FileSystem.FileSystem, trackingFs), + Effect.exit, + ); + const firstPathsExist = firstPaths.map((path) => existsSync(path)); + yield* Effect.forEach( + firstPaths, + (path) => fs.remove(path, { recursive: true, force: true }), + { discard: true }, + ); + return { firstPaths, firstPathsExist, secondExit }; + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + expect(result.firstPaths).toHaveLength(2); + expect(createdPaths).toEqual(result.firstPaths); + expect(result.firstPathsExist).toEqual([true, true]); + expect(Exit.isFailure(result.secondExit)).toBe(true); + if (Exit.isFailure(result.secondExit)) { + expect(Cause.findErrorOption(result.secondExit.cause)).toMatchObject({ + _tag: "Some", + value: { _tag: "StackBuildError" }, + }); + } + expect(result.firstPaths.every((path) => !existsSync(path))).toBe(true); + }); + + it("releases a resolver lease when creation is interrupted", async () => { + const leasedPort = Deferred.makeUnsafe(); + const resolveBlocked: ResolveConfigEffect = (_config, options) => + Effect.gen(function* () { + yield* Deferred.succeed(leasedPort, options?.ports.apiPort ?? 0); + return yield* Effect.never; + }); + + const fiber = Effect.runFork( + createStack( + { mode: "native" }, + platformFactory, + { mode: "native", containerRuntime: null }, + resolveBlocked, + ).pipe(Effect.provide(NodeServices.layer)), + ); + const port = await Effect.runPromise(Deferred.await(leasedPort)); + await Effect.runPromise(Fiber.interrupt(fiber)); + + const server = createServer(); + let bound = false; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + bound = true; + resolve(); + }); + }); + } finally { + if (bound) { + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + } + } + expect(bound).toBe(true); + }); + + it("shares foreground disposal completion across concurrent callers", async () => { + const finalizerStarted = Deferred.makeUnsafe(); + const releaseFinalizer = Deferred.makeUnsafe(); + const gatedPlatformFactory: PlatformFactory = (options) => + Layer.mergeAll( + platformFactory(options), + Layer.effectDiscard( + Effect.addFinalizer(() => + Deferred.succeed(finalizerStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizer)), + Effect.asVoid, + ), + ), + ), + ); + + const stack = await Effect.runPromise( + createStack( + { + mode: "native", + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + gatedPlatformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), + ); + handles.push(stack); + const publicStack = toStackHandle(stack); + + const secondInvoked = Deferred.makeUnsafe(); + const secondDone = Deferred.makeUnsafe(); + const firstDisposal = Effect.runFork(Effect.promise(() => publicStack.dispose())); + await Effect.runPromise(Deferred.await(finalizerStarted)); + const secondDisposal = Effect.runFork( + Effect.promise(() => { + Effect.runSync(Deferred.succeed(secondInvoked, undefined)); + return publicStack.dispose(); + }).pipe(Effect.andThen(Deferred.succeed(secondDone, undefined)), Effect.asVoid), + ); + + await Effect.runPromise(Deferred.await(secondInvoked)); + expect(Option.isNone(await Effect.runPromise(Deferred.poll(secondDone)))).toBe(true); + await Effect.runPromise(Deferred.succeed(releaseFinalizer, undefined)); + const [firstExit, secondExit] = await Effect.runPromise( + Effect.all([Fiber.await(firstDisposal), Fiber.await(secondDisposal)]), + ); + expect(Exit.isSuccess(firstExit)).toBe(true); + expect(Exit.isSuccess(secondExit)).toBe(true); + }); }); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index a80dfc0b04..794428b771 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -1,10 +1,24 @@ import type { LogEntry } from "@supabase/process-compose"; -import { Context, Effect, FileSystem, type Layer, ManagedRuntime, Path, Stream } from "effect"; +import { + Cause, + Context, + Deferred, + Effect, + Exit, + FileSystem, + type Layer, + ManagedRuntime, + Option, + Path, + Result, + Stream, +} from "effect"; import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ApiProxy } from "./ApiProxy.ts"; +import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; -import { toStackError } from "./errors.ts"; +import { toStackError, type StackError } from "./errors.ts"; import type { FunctionsReloadConfig } from "./functions.ts"; import { foregroundLayer } from "./layers.ts"; import { LocalStackLifecycle } from "./LocalStack.ts"; @@ -12,7 +26,7 @@ import { reservePortSet, type PortLease } from "./PortAllocator.ts"; import { Stack } from "./Stack.ts"; import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; import type { ReadyOptions, ResolvedStackConfig, StackConfig } from "./StackConfig.ts"; -import { resolveConfig } from "./StackConfigResolver.ts"; +import { portRequestsForConfig, type ResolveConfigOptions } from "./StackConfigResolver.ts"; import type { StackServiceState } from "./StackServiceState.ts"; type PlatformServices = @@ -22,6 +36,7 @@ type PlatformServices = | HttpServer.HttpServer; type PlatformLayer = Layer.Layer; + /** Supplies the platform HTTP server used by the stack and HTTP proxy. */ interface PlatformFactoryOptions { readonly apiPort: number; @@ -30,141 +45,233 @@ interface PlatformFactoryOptions { export type PlatformFactory = (options: PlatformFactoryOptions) => PlatformLayer; -/** @internal Converts operation failures and closes a terminal foreground runtime. */ -export async function runForegroundOperation( - operation: Promise, - isDisposed: () => Promise, - dispose: () => Promise, -): Promise { - try { - return await operation; - } catch (error: unknown) { - const stackError = toStackError(error); - if (await isDisposed()) { - await dispose(); - } - throw stackError; - } -} +export type ResolveConfigEffect = ( + input: StackConfig | undefined, + options: ResolveConfigOptions, +) => Effect.Effect; -export interface StackHandle extends AsyncDisposable { +/** The internal foreground handle; public adapters live at the package edge. */ +export interface ForegroundStackHandle { readonly url: string; readonly dbUrl: string; readonly publishableKey: string; readonly secretKey: string; - start(): Promise; - stop(): Promise; - dispose(): Promise; - startService(name: string): Promise; - stopService(name: string): Promise; - restartService(name: string): Promise; - reloadFunctions(opts?: FunctionsReloadConfig): Promise; - reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; - ready(opts?: ReadyOptions): Promise; - serviceReady(name: string, opts?: ReadyOptions): Promise; - getStatus(): Promise>; - getServiceStatus(name: string): Promise; - statusChanges(): AsyncIterable; - logs(): AsyncIterable; - serviceLogs(name: string): AsyncIterable; - logHistory(name: string, limit?: number): Promise>; + start(): Effect.Effect; + stop(): Effect.Effect; + dispose(): Effect.Effect; + startService(name: string): Effect.Effect; + stopService(name: string): Effect.Effect; + restartService(name: string): Effect.Effect; + reloadFunctions(opts?: FunctionsReloadConfig): Effect.Effect; + reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Effect.Effect; + ready(opts?: ReadyOptions): Effect.Effect; + serviceReady(name: string, opts?: ReadyOptions): Effect.Effect; + getStatus(): Effect.Effect, StackError>; + getServiceStatus(name: string): Effect.Effect; + statusChanges(): Stream.Stream; + logs(): Stream.Stream; + serviceLogs(name: string): Stream.Stream; + logHistory(name: string, limit?: number): Effect.Effect, StackError>; } -export async function createStack( +/** @internal Converts operation failures and closes a terminal foreground runtime. */ +export function runForegroundOperation( + operation: Effect.Effect, + isDisposed: Effect.Effect, + dispose: Effect.Effect, +): Effect.Effect { + return operation.pipe( + Effect.catch((error) => + Effect.uninterruptible( + Effect.gen(function* () { + if (yield* isDisposed) { + yield* dispose; + } + return yield* Effect.fail(toStackError(error)); + }), + ), + ), + ); +} + +const MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS = 3; + +/** + * The port lease is intentionally released just before the HTTP server binds. + * Another process can claim that port in the small handoff window, so a new + * foreground stack may retry its automatic API-port allocation. Explicit API + * ports never enter this retry path. + */ +const isAddressInUse = (error: unknown, depth = 0): boolean => { + if (depth > 8 || typeof error !== "object" || error === null) return false; + if ("code" in error && Reflect.get(error, "code") === "EADDRINUSE") return true; + if ("cause" in error) return isAddressInUse(Reflect.get(error, "cause"), depth + 1); + return false; +}; + +const causeIsAddressInUse = (cause: Cause.Cause): boolean => { + const failure = Cause.findErrorOption(cause); + if (Option.isSome(failure)) return isAddressInUse(failure.value); + const defect = Cause.findDefect(cause); + return Result.isSuccess(defect) && isAddressInUse(defect.success); +}; + +const createStackAttempt = ( config: StackConfig | undefined, platformFactory: PlatformFactory, -): Promise { - let portLease: PortLease | undefined; - let resolved: ResolvedStackConfig; - try { - resolved = await resolveConfig(config, { - portAllocator: (requests, options) => - reservePortSet(requests, options).pipe( - Effect.tap((lease) => - Effect.sync(() => { - portLease = lease; - }), + runtimeSelection: StackRuntimeSelection, + resolveConfig: ResolveConfigEffect, + preferredApiPort?: number, +): Effect.Effect => + Effect.gen(function* () { + let portLease: PortLease | undefined; + let resolved: ResolvedStackConfig | undefined; + let disposeRuntime: Effect.Effect | undefined; + + const cleanup = Effect.uninterruptible( + Effect.gen(function* () { + if (disposeRuntime !== undefined) { + yield* disposeRuntime.pipe(Effect.ignore); + } + if (portLease !== undefined) { + yield* portLease.releaseAll.pipe(Effect.ignore); + } + if (resolved === undefined) { + return; + } + if (resolved.runtime.mode === "docker") { + yield* dockerForceRemove( + resolved.runtime.containerRuntime, + candidateCleanupTargets(resolved).dockerContainerNames, + ).pipe(Effect.ignore); + } + yield* cleanupAutoManagedPaths(resolved!.autoManagedPaths); + }), + ); + + const attempt = Effect.gen(function* () { + const requests = yield* portRequestsForConfig(config, { + runtime: runtimeSelection, + ...(preferredApiPort === undefined + ? {} + : { preferredPorts: { apiPort: preferredApiPort } }), + }); + const lease = yield* reservePortSet(requests); + portLease = lease; + resolved = yield* resolveConfig(config, { + runtime: runtimeSelection, + ports: lease.ports, + }); + + const fullLayer = foregroundLayer(resolved, platformFactory, lease); + const managedRuntime = ManagedRuntime.make(fullLayer); + disposeRuntime = managedRuntime.disposeEffect; + return yield* Effect.gen(function* () { + const services = yield* managedRuntime.contextEffect; + const localStack = Context.get(services, Stack); + const apiProxy = Context.get(services, ApiProxy); + const lifecycle = Context.get(services, LocalStackLifecycle); + const info = yield* Effect.provideContext(localStack.getInfo(), services); + + const disposalCompletion = Deferred.makeUnsafe>(); + let disposalStarted = false; + const awaitDisposal = Deferred.await(disposalCompletion).pipe( + Effect.flatMap((exit) => + Exit.isSuccess(exit) ? Effect.void : Effect.failCause(exit.cause), ), - Effect.map((lease) => lease.ports), - ), - }); - } catch (error: unknown) { - if (portLease !== undefined) { - await Effect.runPromise(portLease.releaseAll); - } - throw error; - } - - if (portLease === undefined) { - throw new Error("Stack port allocation completed without a port lease"); - } - - try { - const fullLayer = foregroundLayer(resolved, platformFactory, portLease); - const runtime = ManagedRuntime.make(fullLayer); - - try { - const services = await runtime.context(); - const localStack = Context.get(services, Stack); - const apiProxy = Context.get(services, ApiProxy); - const lifecycle = Context.get(services, LocalStackLifecycle); - const info = await runtime.runPromise(localStack.getInfo()); - - let disposal: Promise | undefined; - const gracefulDispose = () => { - disposal ??= runtime.dispose().catch(() => {}); - return disposal; - }; - const run = (effect: Effect.Effect) => - runForegroundOperation( - runtime.runPromise(effect), - () => runtime.runPromise(lifecycle.isDisposed), - gracefulDispose, ); + const dispose = Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposalStarted) { + return restore(awaitDisposal); + } + disposalStarted = true; + return Effect.forkDetach( + managedRuntime.disposeEffect.pipe( + Effect.uninterruptible, + Effect.exit, + Effect.flatMap((exit) => Deferred.succeed(disposalCompletion, exit)), + Effect.asVoid, + ), + { startImmediately: true }, + ).pipe(Effect.asVoid, Effect.andThen(restore(awaitDisposal))); + }), + ); + const run = (effect: Effect.Effect) => + runForegroundOperation( + Effect.provideContext(effect, services), + Effect.provideContext(lifecycle.isDisposed, services), + dispose, + ); - // The HTTP module has no response-flushed hook. Give the proxy's final - // 503 response a brief opportunity to leave the socket before closing - // the runtime after terminal lazy activation. - void runtime - .runPromise(apiProxy.awaitTerminalFailure.pipe(Effect.andThen(Effect.sleep("25 millis")))) - .then(gracefulDispose) - .catch(() => {}); - - const stack: StackHandle = { - url: info.url, - dbUrl: info.dbUrl, - publishableKey: info.publishableKey, - secretKey: info.secretKey, - start: () => run(localStack.start()), - stop: () => run(localStack.stop()), - dispose: gracefulDispose, - startService: (name) => run(localStack.startService(name)), - stopService: (name) => run(localStack.stopService(name)), - restartService: (name) => run(localStack.restartService(name)), - reloadFunctions: (opts) => run(localStack.reloadFunctions(opts)), - reloadEdgeRuntime: (opts) => run(localStack.reloadEdgeRuntime(opts)), - ready: (opts) => run(localStack.waitAllReady(opts)), - serviceReady: (name, opts) => run(localStack.waitReady(name, opts)), - getStatus: () => run(localStack.getAllStates()), - getServiceStatus: (name) => run(localStack.getState(name)), - statusChanges: () => Stream.toAsyncIterableWith(localStack.allStateChanges(), services), - logs: () => Stream.toAsyncIterableWith(localStack.subscribeAllLogs(), services), - serviceLogs: (name) => Stream.toAsyncIterableWith(localStack.subscribeLogs(name), services), - logHistory: (name, limit) => run(localStack.logHistory(name, limit)), - [Symbol.asyncDispose]: gracefulDispose, - }; - - return stack; - } catch (error: unknown) { - await runtime.dispose().catch(() => {}); - throw error; - } - } catch (error: unknown) { - await Effect.runPromise(portLease.releaseAll); - await Effect.runPromise( - dockerForceRemove(candidateCleanupTargets(resolved).dockerContainerNames), + // The HTTP module has no response-flushed hook. Give the proxy's final + // 503 response a brief opportunity to leave the socket before closing + // the runtime after terminal lazy activation. + managedRuntime.runFork( + apiProxy.awaitTerminalFailure.pipe( + Effect.andThen(Effect.sleep("25 millis")), + Effect.andThen(dispose), + Effect.catchCause(() => Effect.void), + ), + ); + + return { + url: info.url, + dbUrl: info.dbUrl, + publishableKey: info.publishableKey, + secretKey: info.secretKey, + start: () => run(localStack.start()), + stop: () => run(localStack.stop()), + dispose: () => dispose, + startService: (name: string) => run(localStack.startService(name)), + stopService: (name: string) => run(localStack.stopService(name)), + restartService: (name: string) => run(localStack.restartService(name)), + reloadFunctions: (opts?: FunctionsReloadConfig) => run(localStack.reloadFunctions(opts)), + reloadEdgeRuntime: (opts: EdgeRuntimeReloadConfig) => + run(localStack.reloadEdgeRuntime(opts)), + ready: (opts?: ReadyOptions) => run(localStack.waitAllReady(opts)), + serviceReady: (name: string, opts?: ReadyOptions) => + run(localStack.waitReady(name, opts)), + getStatus: () => run(localStack.getAllStates()), + getServiceStatus: (name: string) => run(localStack.getState(name)), + statusChanges: () => localStack.allStateChanges(), + logs: () => localStack.subscribeAllLogs(), + serviceLogs: (name: string) => localStack.subscribeLogs(name), + logHistory: (name: string, limit?: number) => run(localStack.logHistory(name, limit)), + } satisfies ForegroundStackHandle; + }); + }); + + return yield* attempt.pipe( + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : cleanup)), + ); + }); + +export function createStack( + config: StackConfig | undefined, + platformFactory: PlatformFactory, + runtime: StackRuntimeSelection, + resolveConfig: ResolveConfigEffect, +): Effect.Effect { + const automaticApiPort = config?.port === undefined; + const loop = ( + attempt: number, + ): Effect.Effect => + createStackAttempt( + config, + platformFactory, + runtime, + resolveConfig, + attempt === 0 ? undefined : 0, + ).pipe( + Effect.catchCause((cause) => + automaticApiPort && + causeIsAddressInUse(cause) && + attempt + 1 < MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS + ? Effect.suspend(() => loop(attempt + 1)) + : Effect.failCause(cause), + ), ); - cleanupAutoManagedPaths(resolved); - throw toStackError(error); - } + + return loop(0).pipe(Effect.mapError(toStackError)); } diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index d0962a7191..6c162a1c2c 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -1,19 +1,77 @@ import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Cause, Effect, Exit, Result } from "effect"; import { existsSync } from "node:fs"; import { basename, dirname } from "node:path"; import { candidateCleanupTargets, cleanupAutoManagedPaths } from "./cleanup.ts"; import { dockerContainerName } from "./StackIdentity.ts"; import { runForegroundOperation } from "./createStack.ts"; -import { StackReadinessError } from "./errors.ts"; +import { ChecksumMismatchError, StackReadinessError } from "./errors.ts"; import { shortTempPrefixRoot } from "./paths.ts"; -import { resolveConfig, sanitizeDaemonConfigInput } from "./StackConfigResolver.ts"; +import { + portRequestsForConfig, + resolveConfig as resolveConfigEffect, + type ResolveConfigOptions, + sanitizeDaemonConfigInput, +} from "./StackConfigResolver.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; +import type { PortField, PortSet } from "./PortCatalog.ts"; + +const testPorts = (config?: Parameters[0]): PortSet => { + const ports = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + edgeRuntimePort: 40_005, + edgeRuntimeInspectorPort: 40_006, + realtimePort: 40_007, + storagePort: 40_008, + imgproxyPort: 40_009, + mailpitPort: 40_010, + mailpitSmtpPort: 40_011, + mailpitPop3Port: 40_012, + pgmetaPort: 40_013, + studioPort: 40_014, + analyticsPort: 40_015, + poolerPort: 40_016, + poolerApiPort: 40_017, + } satisfies Record; + return { ...ports, apiPort: config?.port ?? ports.apiPort }; +}; + +const resolveConfig = ( + config?: Parameters[0], + options?: Partial, +) => + Effect.runPromise( + resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts(config) }).pipe( + Effect.provide(NodeFileSystem.layer), + ), + ); describe("foreground operation lifecycle", () => { + it("preserves checksum mismatch classification for non-Effect consumers", async () => { + const mismatch = new ChecksumMismatchError({ + url: "https://example.com/archive.tar.gz", + expected: "expected", + actual: "actual", + }); + + await expect( + Effect.runPromise( + runForegroundOperation(Effect.fail(mismatch), Effect.succeed(false), Effect.void), + ), + ).rejects.toMatchObject({ + code: "CHECKSUM_MISMATCH", + cause: mismatch, + }); + }); + it("disposes the foreground runtime after a direct readiness timeout", async () => { let disposeCount = 0; - const operation = Promise.reject( + const operation = Effect.fail( new StackReadinessError({ target: "stack", timeoutMs: 10, @@ -22,12 +80,14 @@ describe("foreground operation lifecycle", () => { ); await expect( - runForegroundOperation( - operation, - async () => true, - async () => { - disposeCount += 1; - }, + Effect.runPromise( + runForegroundOperation( + operation, + Effect.succeed(true), + Effect.sync(() => { + disposeCount += 1; + }), + ), ), ).rejects.toMatchObject({ code: "STACK_READINESS_TIMEOUT" }); expect(disposeCount).toBe(1); @@ -37,12 +97,14 @@ describe("foreground operation lifecycle", () => { let disposeCount = 0; await expect( - runForegroundOperation( - Promise.reject(new Error("service startup failed")), - async () => true, - async () => { - disposeCount += 1; - }, + Effect.runPromise( + runForegroundOperation( + Effect.fail(new Error("service startup failed")), + Effect.succeed(true), + Effect.sync(() => { + disposeCount += 1; + }), + ), ), ).rejects.toMatchObject({ code: "UNKNOWN" }); expect(disposeCount).toBe(1); @@ -52,14 +114,41 @@ describe("foreground operation lifecycle", () => { let disposeCount = 0; await expect( + Effect.runPromise( + runForegroundOperation( + Effect.fail(new Error("failed")), + Effect.succeed(false), + Effect.sync(() => { + disposeCount += 1; + }), + ), + ), + ).rejects.toMatchObject({ code: "UNKNOWN" }); + expect(disposeCount).toBe(0); + }); + + it("preserves defects without converting them into expected stack failures", async () => { + const defect = new Error("unexpected runtime defect"); + let disposeCount = 0; + + const exit = await Effect.runPromiseExit( runForegroundOperation( - Promise.reject(new Error("failed")), - async () => false, - async () => { + Effect.die(defect), + Effect.succeed(true), + Effect.sync(() => { disposeCount += 1; - }, + }), ), - ).rejects.toMatchObject({ code: "UNKNOWN" }); + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const found = Cause.findDefect(exit.cause); + expect(Result.isSuccess(found)).toBe(true); + if (Result.isSuccess(found)) { + expect(found.success).toBe(defect); + } + } expect(disposeCount).toBe(0); }); }); @@ -79,14 +168,42 @@ describe("resolveConfig edge runtime defaults", () => { it("disables edge runtime when omitted in native mode", async () => { const config = await resolveConfig({ mode: "native" }); - expect(config.mode).toBe("native"); + expect(config.runtime).toEqual({ mode: "native", containerRuntime: null }); expect(config.edgeRuntime).toBe(false); }); - it("enables edge runtime when omitted in auto mode", async () => { - const config = await resolveConfig(); + it("enables edge runtime when omitted in Docker mode", async () => { + const config = await resolveConfig( + { mode: "docker" }, + { + runtime: { mode: "docker", containerRuntime: "docker" }, + }, + ); + + expect(config.runtime).toEqual({ mode: "docker", containerRuntime: "docker" }); + expect(config.edgeRuntime).toEqual( + expect.objectContaining({ + enabled: true, + version: DEFAULT_VERSIONS["edge-runtime"], + }), + ); + }); + + it("requires Effect consumers to provide the selected Docker runtime", async () => { + await expect( + Effect.runPromise(portRequestsForConfig({ mode: "docker" })), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); - expect(config.mode).toBe("auto"); + it("applies the detected Docker mode before resolving services and ports", async () => { + const config = await resolveConfig(undefined, { + runtime: { mode: "docker", containerRuntime: "podman" }, + }); + + expect(config.runtime).toEqual({ mode: "docker", containerRuntime: "podman" }); expect(config.edgeRuntime).toEqual( expect.objectContaining({ enabled: true, @@ -98,7 +215,7 @@ describe("resolveConfig edge runtime defaults", () => { it("preserves explicit edge runtime opt-in in native mode for builder validation", async () => { const config = await resolveConfig({ mode: "native", edgeRuntime: {} }); - expect(config.mode).toBe("native"); + expect(config.runtime).toEqual({ mode: "native", containerRuntime: null }); expect(config.edgeRuntime).toEqual( expect.objectContaining({ enabled: true, @@ -108,67 +225,85 @@ describe("resolveConfig edge runtime defaults", () => { }); }); -describe("resolveConfig explicit keyless ports", () => { - it("preserves an explicit pooler api port", async () => { - const config = await resolveConfig({ - mode: "docker", - edgeRuntime: false, - postgrest: false, - auth: false, - pooler: { port: 42423, apiPort: 42424 }, +describe("portRequestsForConfig explicit ports", () => { + it("rejects an explicit zero port before invoking allocation", async () => { + await expect( + Effect.runPromise(portRequestsForConfig({ mode: "native", port: 0 })), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", }); + }); - expect(config.ports.poolerPort).toBe(42423); - expect(config.ports.poolerApiPort).toBe(42424); + it.each([1.5, -1, 65_536])("rejects an explicit invalid port %s", async (port) => { + await expect( + Effect.runPromise(portRequestsForConfig({ mode: "native", port })), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); }); - it("orders explicit ports before omitted fields claim their preferred values", async () => { - const sharedCandidate = 61_234; + it("preserves an explicit pooler api port", async () => { const config = await resolveConfig( { - mode: "native", + mode: "docker", edgeRuntime: false, postgrest: false, auth: false, - analytics: { port: sharedCandidate }, + pooler: { port: 42423, apiPort: 42424 }, }, { - preferredPorts: { dbPort: sharedCandidate }, - portAllocator: (requests) => { - expect(requests[0]).toEqual({ - field: "analyticsPort", - selection: { kind: "exact", port: sharedCandidate }, - }); - return Effect.succeed({ - apiPort: 61_233, - dbPort: 61_235, - analyticsPort: sharedCandidate, - }); - }, + runtime: { mode: "docker", containerRuntime: "docker" }, + ports: { ...testPorts(), poolerPort: 42423, poolerApiPort: 42424 }, }, ); - expect(config.ports.analyticsPort).toBe(sharedCandidate); - expect(config.ports.dbPort).not.toBe(sharedCandidate); + expect(config.ports.poolerPort).toBe(42423); + expect(config.ports.poolerApiPort).toBe(42424); + }); + + it("orders explicit ports before automatic requests", async () => { + const sharedCandidate = 61_234; + const requests = await Effect.runPromise( + portRequestsForConfig( + { + mode: "native", + edgeRuntime: false, + postgrest: false, + auth: false, + analytics: { port: sharedCandidate }, + }, + { preferredPorts: { dbPort: sharedCandidate } }, + ), + ); + expect(requests[0]).toEqual({ + field: "analyticsPort", + selection: { kind: "exact", port: sharedCandidate }, + }); + expect(requests[1]?.field).toBe("apiPort"); }); }); describe("candidateCleanupTargets", () => { it("derives fallback Docker identities from enabled catalog services", async () => { - const config = await resolveConfig({ - mode: "docker", - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }); + const config = await resolveConfig( + { + mode: "docker", + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); expect(candidateCleanupTargets(config)).toEqual({ dockerContainerNames: [ @@ -180,7 +315,10 @@ describe("candidateCleanupTargets", () => { it("keys fallback Docker identities by the stack's own identity when it has one", async () => { const instanceId = "0f9d2b3c-4a5e-4c7d-8e9f-1a2b3c4d5e6f"; - const config = await resolveConfig({ mode: "docker", instanceId }); + const config = await resolveConfig( + { mode: "docker", instanceId }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); expect(config.instanceId).toBe(instanceId); const { dockerContainerNames } = candidateCleanupTargets(config); @@ -211,21 +349,18 @@ describe("resolveConfig instanceId validation", () => { }); }); -describe("resolveConfig startup mode", () => { - it("keeps eager startup as the package default", async () => { - const config = await resolveConfig(); - expect(config.startupMode).toBe("eager"); - }); - - it("preserves an explicit lazy startup mode", async () => { - const config = await resolveConfig({ startupMode: "lazy" }); - expect(config.startupMode).toBe("lazy"); +describe("resolveConfig service policies", () => { + it("uses catalog defaults and resolves explicit policies", async () => { + const config = await resolveConfig({ servicePolicies: { postgrest: "eager" } }); + expect(config.servicePolicies.postgres).toBe("eager"); + expect(config.servicePolicies.postgrest).toBe("eager"); + expect(config.servicePolicies.auth).toBe("lazy"); }); }); describe("resolveConfig state roots", () => { it("uses disposable temporary roots when direct callers omit them", async () => { - const config = await resolveConfig({ startupMode: "lazy" }); + const config = await resolveConfig(); try { expect(config.autoManagedPaths).toEqual([config.stackRoot, config.runtimeRoot]); @@ -236,7 +371,9 @@ describe("resolveConfig state roots", () => { expect(existsSync(config.stackRoot)).toBe(true); expect(existsSync(config.runtimeRoot)).toBe(true); } finally { - cleanupAutoManagedPaths(config); + await Effect.runPromise( + cleanupAutoManagedPaths(config.autoManagedPaths).pipe(Effect.provide(NodeFileSystem.layer)), + ); } expect(existsSync(config.stackRoot)).toBe(false); diff --git a/packages/stack/src/discovery.ts b/packages/stack/src/discovery.ts index cddeec124e..41645b4136 100644 --- a/packages/stack/src/discovery.ts +++ b/packages/stack/src/discovery.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import type { ManagedStackManagerError, ManagedStackManagerShape } from "./managed/manager.ts"; import { ManagedStackManager } from "./managed/manager.ts"; -import type { ManagedStackDocument } from "./managed/document.ts"; +import { InvalidManagedStackDocumentError, type ManagedStackDocument } from "./managed/document.ts"; import { connectManagedStack, deleteManagedStack, @@ -12,6 +12,7 @@ import { PORT_CATALOG, PORT_FIELDS, type ResolvedPorts } from "./PortCatalog.ts" import type { PartialVersionManifest } from "./versions.ts"; import { NoRunningStackError } from "./managed/model.ts"; import type { ManagedPortDrift, ManagedPortIntentDocument } from "./managed/model.ts"; +import { managedStackDocumentPathEffect } from "./managed/paths.ts"; import { HttpTransportClient } from "./HttpTransportClient.ts"; import type { Stack } from "./Stack.ts"; @@ -25,7 +26,7 @@ export interface StackSummary { readonly dbUrl?: string; readonly startedAt?: string; readonly lastNotifiedUpdateFingerprint?: string; - readonly launch?: ManagedStackDocument["launch"]; + readonly launch: ManagedStackDocument["launch"]; readonly drift?: ReadonlyArray; } @@ -38,18 +39,19 @@ const portFieldByKey: Readonly> = Object.fro const summaryForDocument = ( document: ManagedStackDocument & { readonly drift?: ReadonlyArray }, + path: string, running?: boolean, -): StackSummary => { +): Effect.Effect => { const ports: Record = {}; for (const assignment of document.ports) { const field = portFieldByKey[assignment.key]; if (field !== undefined) ports[field] = assignment.port; } if (ports.apiPort === undefined || ports.dbPort === undefined) { - throw new Error("Managed stack document is missing api.port or db.port"); + return Effect.fail(new InvalidManagedStackDocumentError({ path })); } const { apiPort, dbPort } = ports; - return { + return Effect.succeed({ name: document.identity.name, running: running ?? (document.lifecycle === "running" && document.runtime !== undefined), ports: { @@ -57,15 +59,15 @@ const summaryForDocument = ( apiPort, dbPort, }, - versions: document.launch?.versions ?? {}, - ...(document.launch === undefined ? {} : { launch: document.launch }), + versions: document.launch.versions, + launch: document.launch, ...(document.drift === undefined ? {} : { drift: document.drift }), - ...(document.launch?.lastNotifiedUpdateFingerprint === undefined + ...(document.launch.lastNotifiedUpdateFingerprint === undefined ? {} : { lastNotifiedUpdateFingerprint: document.launch.lastNotifiedUpdateFingerprint }), ...(document.runtime === undefined ? {} : { pid: document.runtime.pid }), startedAt: document.updatedAt, - }; + }); }; const liveStatus = ( @@ -100,7 +102,11 @@ export const listStacks = (opts: { return undefined; } const running = yield* liveStatus(manager, listing.document); - return summaryForDocument(listing.document, running); + return yield* summaryForDocument( + listing.document, + yield* managedStackDocumentPathEffect(manager.stateRoot, listing.document.id), + running, + ); }), ); return summaries @@ -127,7 +133,11 @@ export const resolveStackSummary = (opts: { ...(opts.portDocument === undefined ? {} : { portDocument: opts.portDocument }), }); const manager = yield* ManagedStackManager; - return summaryForDocument(document, yield* liveStatus(manager, document)); + return yield* summaryForDocument( + document, + yield* managedStackDocumentPathEffect(manager.stateRoot, document.id), + yield* liveStatus(manager, document), + ); }); export const stopDaemon = (opts: { diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts index 490b6cedd8..409b895276 100644 --- a/packages/stack/src/effect-bun.ts +++ b/packages/stack/src/effect-bun.ts @@ -66,7 +66,7 @@ export const updateManagedLaunch = (opts: { readonly stackName?: string; readonly cwd?: string; readonly cacheRoot: string; - readonly launch: NonNullable; + readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( Effect.provide(managedLayer(opts.cacheRoot)), diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts index 97f5841af2..1959dbce15 100644 --- a/packages/stack/src/effect-node.ts +++ b/packages/stack/src/effect-node.ts @@ -66,7 +66,7 @@ export const updateManagedLaunch = (opts: { readonly stackName?: string; readonly cwd?: string; readonly cacheRoot: string; - readonly launch: NonNullable; + readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( Effect.provide(managedLayer(opts.cacheRoot)), diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 79f2e1542c..f77e4fcfee 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -5,7 +5,10 @@ export type { StackServiceStatus } from "./StackServiceState.ts"; export { StackServiceState, fromRawServiceState } from "./StackServiceState.ts"; export { + BinaryHostCompatibilityError, + BinaryManifestError, BinaryNotFoundError, + BinaryRuntimeError, ChecksumMismatchError, DockerPullError, DownloadError, @@ -13,21 +16,20 @@ export { PortConflictError, StackBuildError, StackError, + StackNotRunningError, StackReadinessError, toStackError, } from "./errors.ts"; -export type { PlatformInfo } from "./Platform.ts"; -export { - authAssetName, - detectPlatform, - postgresAssetName, - postgrestAssetName, -} from "./Platform.ts"; +export type { NativeTarget, PlatformInfo } from "./Platform.ts"; +export { detectPlatform, nativeTargetForPlatform } from "./Platform.ts"; + +export type { ContainerRuntime, StackRuntimeSelection } from "./ContainerRuntime.ts"; +export { selectStackRuntime, validateStackRuntime } from "./ContainerRuntime.ts"; -export type { ServiceResolution } from "./StackPreparation.ts"; +export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; -export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; +export type { PrefetchEffectOptions, PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export { prefetch } from "./prefetch.ts"; export { @@ -53,7 +55,7 @@ export type { PortSelection, PortSelectionOptions, } from "./PortAllocator.ts"; -export { allocatePortSet, PortAllocationError, reservePortSet } from "./PortAllocator.ts"; +export { PortAllocationError, reservePortSet } from "./PortAllocator.ts"; export { AllocatedPortsSchema, DEFAULT_API_PORT, @@ -95,6 +97,9 @@ export type { ResolvedVectorConfig, ReadinessPolicy, ReadyOptions, + ServicePolicy, + ServicePolicyManifest, + StackMode, StackConfig, StorageConfig, StudioConfig, @@ -128,7 +133,6 @@ export { dockerImageForService, fillServiceVersionManifest, fullVersionManifest, - IMAGE_TAG_PREFIX, normalizeServiceVersion, normalizeServiceVersions, SERVICE_NAMES, @@ -146,7 +150,8 @@ export { NoRunningStackError } from "./managed/model.ts"; export type { PartialVersionManifest } from "./versions.ts"; export { PartialVersionManifestSchema } from "./versions.ts"; -export { resolveConfig } from "./StackConfigResolver.ts"; +export { portRequestsForConfig, resolveConfig } from "./StackConfigResolver.ts"; +export type { PortRequestOptions, ResolveConfigOptions } from "./StackConfigResolver.ts"; export { DaemonStartError } from "./layers.ts"; export type { ManagedDaemonConfigInput } from "./layers.ts"; diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 4d30ce2c47..49018edc13 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -1,4 +1,4 @@ -import { Data } from "effect"; +import { Data, Predicate } from "effect"; export class BinaryNotFoundError extends Data.TaggedError("BinaryNotFoundError")<{ readonly service: string; @@ -16,6 +16,21 @@ export class ChecksumMismatchError extends Data.TaggedError("ChecksumMismatchErr readonly actual: string; }> {} +export class BinaryManifestError extends Data.TaggedError("BinaryManifestError")<{ + readonly url: string; + readonly detail: string; +}> {} + +export class BinaryRuntimeError extends Data.TaggedError("BinaryRuntimeError")<{ + readonly path: string; + readonly detail: string; +}> {} + +export class BinaryHostCompatibilityError extends Data.TaggedError("BinaryHostCompatibilityError")<{ + readonly target: string; + readonly detail: string; +}> {} + export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly image: string; readonly detail: string; @@ -43,6 +58,8 @@ export const isDockerDaemonDownMessage = (message: string): boolean => { normalized.includes("docker daemon is not running") || normalized.includes("docker desktop is not running") || normalized.includes("is the docker daemon running") || + normalized.includes("cannot connect to podman") || + normalized.includes("error during connect") || // Spawn succeeds but the socket is not accessible (e.g. a Linux user // missing docker group membership) — a local setup problem, not a // registry failure. @@ -87,81 +104,41 @@ export class StackError extends Error { } } +const taggedStackErrorCodes = [ + ["ServiceNotFoundError", "SERVICE_NOT_FOUND"], + ["StackBuildError", "BUILD_ERROR"], + ["StackNotRunningError", "STACK_NOT_RUNNING"], + ["StackReadinessError", "STACK_READINESS_TIMEOUT"], + ["BinaryNotFoundError", "BINARY_NOT_FOUND"], + ["ChecksumMismatchError", "CHECKSUM_MISMATCH"], + ["BinaryManifestError", "BINARY_MANIFEST"], + ["BinaryRuntimeError", "BINARY_RUNTIME"], + ["BinaryHostCompatibilityError", "BINARY_HOST"], + ["DownloadError", "DOWNLOAD_ERROR"], + ["DockerPullError", "DOCKER_PULL_ERROR"], + ["PortConflictError", "PORT_CONFLICT"], + ["PortAllocationError", "PORT_ALLOCATION"], + ["ServiceReadyError", "SERVICE_NOT_READY"], +] as const; + +const messageForUnknownError = (error: unknown): string => { + if (error instanceof Error && error.message.length > 0) return error.message; + if (error !== null && typeof error === "object" && "detail" in error) { + const detail = error.detail; + if (typeof detail === "string" && detail.length > 0) return detail; + } + return String(error); +}; + export function toStackError(err: unknown): StackError { if (err instanceof StackError) return err; - if (err != null && typeof err === "object" && "_tag" in err) { - const tagged = err as { _tag: string; message?: string; detail?: string }; - const taggedMessage = - (tagged.message !== undefined && tagged.message.length > 0 ? tagged.message : undefined) ?? - tagged.detail ?? - String(err); - switch (tagged._tag) { - case "ServiceNotFoundError": - return new StackError({ - code: "SERVICE_NOT_FOUND", - message: taggedMessage, - cause: err, - }); - case "StackBuildError": - return new StackError({ - code: "BUILD_ERROR", - message: taggedMessage, - cause: err, - }); - case "StackNotRunningError": - return new StackError({ - code: "STACK_NOT_RUNNING", - message: taggedMessage, - cause: err, - }); - case "StackReadinessError": - return new StackError({ - code: "STACK_READINESS_TIMEOUT", - message: taggedMessage, - cause: err, - }); - case "BinaryNotFoundError": - return new StackError({ - code: "BINARY_NOT_FOUND", - message: taggedMessage, - cause: err, - }); - case "DownloadError": - return new StackError({ - code: "DOWNLOAD_ERROR", - message: taggedMessage, - cause: err, - }); - case "DockerPullError": - return new StackError({ - code: "DOCKER_PULL_ERROR", - message: taggedMessage, - cause: err, - }); - case "PortConflictError": - return new StackError({ - code: "PORT_CONFLICT", - message: taggedMessage, - cause: err, - }); - case "PortAllocationError": - return new StackError({ - code: "PORT_ALLOCATION", - message: taggedMessage, - cause: err, - }); - case "ServiceReadyError": - return new StackError({ - code: "SERVICE_NOT_READY", - message: taggedMessage, - cause: err, - }); - default: - return new StackError({ - code: tagged._tag, - message: taggedMessage, - cause: err, - }); + for (const [tag, code] of taggedStackErrorCodes) { + if (Predicate.isTagged(err, tag)) { + return new StackError({ + code, + message: messageForUnknownError(err), + cause: err, + }); } } if (err instanceof Error) { diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 46f9298c83..594cd70e58 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; +import { NodeServices } from "@effect/platform-node"; import { mkdtempSync, symlinkSync } from "node:fs"; import { readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect, Schema } from "effect"; -import { resolveConfig } from "./StackConfigResolver.ts"; +import { Effect, Predicate, Schema } from "effect"; +import { + resolveConfig as resolveConfigEffect, + type ResolveConfigOptions, +} from "./StackConfigResolver.ts"; +import type { PortSet } from "./PortCatalog.ts"; import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; import { clearFunctionsRuntimeConfig, @@ -17,6 +21,37 @@ import { } from "./functions.ts"; import { verifyRequest } from "./services/edge-runtime-main.ts"; +const testPorts: PortSet = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + edgeRuntimePort: 40_005, + edgeRuntimeInspectorPort: 40_006, + realtimePort: 40_007, + storagePort: 40_008, + imgproxyPort: 40_009, + mailpitPort: 40_010, + mailpitSmtpPort: 40_011, + mailpitPop3Port: 40_012, + pgmetaPort: 40_013, + studioPort: 40_014, + analyticsPort: 40_015, + poolerPort: 40_016, + poolerApiPort: 40_017, +}; + +const resolveConfig = ( + config?: Parameters[0], + options?: Partial, +) => + Effect.runPromise( + resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); } @@ -84,7 +119,14 @@ const authFailureCases = [ describe("stack Functions runtime config", () => { it("projects an explicit bundle without project discovery", async () => { const root = makeTempProject(); - const stackConfig = await resolveConfig({ projectDir: root, functions: makeBundle(root) }); + const stackConfig = await resolveConfig( + { + mode: "docker", + projectDir: root, + functions: makeBundle(root), + }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); const config = resolveFunctionsRuntimeConfig( stackConfig, { hostname: "127.0.0.1" }, @@ -103,6 +145,29 @@ describe("stack Functions runtime config", () => { await rm(root, { recursive: true, force: true }); }); + it("rejects a function bundle when Edge Runtime is disabled", async () => { + const root = makeTempProject(); + + const error = await resolveConfig({ + mode: "native", + projectDir: root, + functions: makeBundle(root), + }).then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); + if (Predicate.isTagged(error, "StackBuildError")) { + expect(error).toMatchObject({ + reason: "invalid_config", + detail: "Edge Functions require Edge Runtime to be enabled", + }); + } + + await rm(root, { recursive: true, force: true }); + }); + it("validates paths, import maps, and unique function names", async () => { const decode = Schema.decodeUnknownSync(ResolvedFunctionsBundleSchema); const root = makeTempProject(); @@ -212,7 +277,10 @@ describe("stack Functions runtime config", () => { return Effect.gen(function* () { const bundle = makeBundle(cwd); const stackConfig = yield* Effect.promise(() => - resolveConfig({ projectDir: cwd, runtimeRoot: cwd, functions: bundle }), + resolveConfig( + { mode: "docker", projectDir: cwd, runtimeRoot: cwd, functions: bundle }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ), ); yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); @@ -229,7 +297,7 @@ describe("stack Functions runtime config", () => { yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([]); }).pipe( - Effect.provide(BunServices.layer), + Effect.provide(NodeServices.layer), Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), ); }); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 3969233f6f..a482ef0b0a 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -16,6 +16,9 @@ export type { RealtimeConfig, ReadinessPolicy, ReadyOptions, + ServicePolicy, + ServicePolicyManifest, + StackMode, StackConfig, StorageConfig, StudioConfig, @@ -23,9 +26,9 @@ export type { } from "./StackConfig.ts"; export type { ServiceName, VersionManifest } from "./versions.ts"; -export type { ServiceResolution } from "./StackPreparation.ts"; +export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; -export type { StackHandle } from "./createStack.ts"; +export type { StackHandle } from "./stackHandle.ts"; export type { FunctionsReloadConfig, FunctionsRuntimeConfig, diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index f6b8170b57..46204768de 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -15,7 +15,7 @@ import type { ResolvedStackConfig } from "./StackConfig.ts"; import { sanitizeDaemonConfigInput, type DaemonConfigInput } from "./StackConfigResolver.ts"; import { HttpTransportClient } from "./HttpTransportClient.ts"; import { DEFAULT_MANAGED_STACK_NAME, defaultCacheRoot } from "./paths.ts"; -import type { ManagedStackDocument } from "./managed/document.ts"; +import type { ManagedStackLaunchInput } from "./managed/document.ts"; import type { ManagedPortIntentDocument } from "./managed/model.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; @@ -107,7 +107,7 @@ export class DaemonStartError extends Data.TaggedError("DaemonStartError")<{ /** Managed-only additions kept outside the generic daemon config resolver. */ export type ManagedDaemonConfigInput = DaemonConfigInput & { readonly portIntents: ManagedPortIntentDocument; - readonly launch?: ManagedStackDocument["launch"]; + readonly launch?: ManagedStackLaunchInput; }; // --------------------------------------------------------------------------- diff --git a/packages/stack/src/managed-control.integration.test.ts b/packages/stack/src/managed-control.integration.test.ts index 7fd41e05b7..a23cf38e7c 100644 --- a/packages/stack/src/managed-control.integration.test.ts +++ b/packages/stack/src/managed-control.integration.test.ts @@ -1,5 +1,5 @@ import { it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, ManagedRuntime, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, ManagedRuntime, Predicate, Result, Stream } from "effect"; import { HttpServer } from "effect/unstable/http"; import { spawn } from "node:child_process"; import { createServer, type Server } from "node:http"; @@ -13,6 +13,8 @@ import { ControlBindError, ControlTransport, ControlTransportError, + isControlAttached, + isControlOwnership, probeControl, } from "./managed/control.ts"; import { controlTransportLayer } from "./platform-node.ts"; @@ -123,7 +125,7 @@ describe("managed control endpoint", () => { live( Effect.gen(function* () { const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const started = { value: false }; const stackLayer = Layer.succeed(Stack, makeStack(started)); const daemonRuntime = ManagedRuntime.make( @@ -159,7 +161,7 @@ describe("managed control endpoint", () => { live( Effect.gen(function* () { const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const before = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); expect(before.status).toBe(200); expect(yield* Effect.promise(() => before.json())).toMatchObject({ state: "starting" }); @@ -195,7 +197,7 @@ describe("managed control endpoint", () => { live( Effect.gen(function* () { const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const stopCalls = { value: 0 }; const stack = { ...makeStack({ value: false }), @@ -234,7 +236,7 @@ describe("managed control endpoint", () => { live( Effect.gen(function* () { const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const daemonRuntime = ManagedRuntime.make( DaemonServer.layerWithShutdown(Effect.void, owner.ownerStatus, { includeOwnerRoute: false, @@ -245,7 +247,7 @@ describe("managed control endpoint", () => { ); yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); const contender = yield* acquireControl({ stackId: STACK_ID }); - expect(contender._tag).toBe("Attached"); + expect(isControlAttached(contender)).toBe(true); expect(yield* contender.ownerStatus).toMatchObject({ protocolVersion: 1, state: "starting", @@ -270,7 +272,7 @@ describe("managed control endpoint", () => { const secondEndpoint = yield* controlEndpoint(COLLIDING_STACK_ID); expect(secondEndpoint.port).toBe(firstEndpoint.port); const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const daemonRuntime = ManagedRuntime.make( DaemonServer.layerWithShutdown(Effect.void, owner.ownerStatus, { includeOwnerRoute: false, @@ -281,7 +283,7 @@ describe("managed control endpoint", () => { ); yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); const contender = yield* acquireControl({ stackId: COLLIDING_STACK_ID }); - if (contender._tag !== "Owned") throw new Error("expected contender ownership"); + if (!isControlOwnership(contender)) throw new Error("expected contender ownership"); expect(contender.endpoint.port).not.toBe(owner.endpoint.port); // Readers locate each owner at its actual candidate. @@ -292,7 +294,7 @@ describe("managed control endpoint", () => { // A second caller for the collided stack attaches to its owner. const attached = yield* acquireControl({ stackId: COLLIDING_STACK_ID }); - expect(attached._tag).toBe("Attached"); + expect(isControlAttached(attached)).toBe(true); expect(attached.endpoint.port).toBe(contender.endpoint.port); yield* Effect.promise(() => daemonRuntime.dispose()); }), @@ -306,7 +308,7 @@ describe("managed control endpoint", () => { yield* Effect.scoped( Effect.gen(function* () { const owner = yield* acquireControl({ stackId: STACK_ID }); - expect(owner._tag).toBe("Owned"); + expect(isControlOwnership(owner)).toBe(true); }), ); const next = yield* Effect.scoped( @@ -314,7 +316,7 @@ describe("managed control endpoint", () => { return yield* acquireControl({ stackId: STACK_ID }); }), ); - expect(next._tag).toBe("Owned"); + expect(isControlOwnership(next)).toBe(true); }), ), ); @@ -329,7 +331,7 @@ describe("managed control endpoint", () => { (server) => Effect.promise(() => closeRaw(server)), ); const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); expect(owner.endpoint.port).toBe(candidates[1]!.port); expect(unrelated.listening).toBe(true); const probe = yield* probeControl(STACK_ID); @@ -350,16 +352,11 @@ describe("managed control endpoint", () => { (server) => Effect.promise(() => closeRaw(server)), ), ); - const result = yield* acquireControl({ - stackId: STACK_ID, - }).pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Left" as const, error }), - onSuccess: (value) => ({ _tag: "Right" as const, value }), - }), - ); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") expect(result.error._tag).toBe("ControlAddressConflictError"); + const result = yield* acquireControl({ stackId: STACK_ID }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(Predicate.isTagged(result.failure, "ControlAddressConflictError")).toBe(true); + } }), ), ), @@ -398,6 +395,40 @@ describe("managed control endpoint", () => { ), ); + it.live("fails closed when an owner probe encounters ambiguous transport", () => + Effect.scoped( + Effect.gen(function* () { + let reads = 0; + const transport = Layer.succeed(ControlTransport, { + bind: (endpoint) => + Effect.fail( + new ControlBindError({ endpoint, reason: "in-use" as const, cause: "occupied" }), + ), + read: (endpoint) => + Effect.fail( + new ControlTransportError({ + endpoint, + reason: reads++ === 0 ? ("transport" as const) : ("unreachable" as const), + cause: "probe failed", + }), + ), + requestStop: () => Effect.void, + }); + const exit = yield* acquireControl({ stackId: STACK_ID }).pipe( + Effect.result, + Effect.provide(transport), + ); + expect(Result.isFailure(exit)).toBe(true); + if (Result.isFailure(exit)) { + expect(exit.failure).toBeInstanceOf(ControlTransportError); + if (exit.failure instanceof ControlTransportError) { + expect(exit.failure.reason).toBe("transport"); + } + } + }), + ), + ); + it.live("preserves an explicit owner protocol mismatch", () => live( Effect.scoped( @@ -412,17 +443,11 @@ describe("managed control endpoint", () => { ), (server) => Effect.promise(() => closeRaw(server)), ); - const result = yield* acquireControl({ - stackId: STACK_ID, - }).pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Left" as const, error }), - onSuccess: (value) => ({ _tag: "Right" as const, value }), - }), - ); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") - expect(result.error._tag).toBe("ControlProtocolMismatchError"); + const result = yield* acquireControl({ stackId: STACK_ID }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(Predicate.isTagged(result.failure, "ControlProtocolMismatchError")).toBe(true); + } expect(unrelated.listening).toBe(true); }), ), @@ -434,12 +459,12 @@ describe("managed control endpoint", () => { Effect.scoped( Effect.gen(function* () { const owner = yield* acquireControl({ stackId: STACK_ID }); - if (owner._tag !== "Owned") throw new Error("expected control ownership"); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const attached = yield* acquireControl({ stackId: STACK_ID }); - expect(attached._tag).toBe("Attached"); + expect(isControlAttached(attached)).toBe(true); yield* owner.close; const next = yield* acquireControl({ stackId: STACK_ID }); - expect(next._tag).toBe("Owned"); + expect(isControlOwnership(next)).toBe(true); }), ), ), @@ -455,7 +480,7 @@ describe("managed control endpoint", () => { child.child.kill("SIGKILL"); yield* Effect.promise(() => child.exited); const owner = yield* acquireControl({ stackId: STACK_ID }); - expect(owner._tag).toBe("Owned"); + expect(isControlOwnership(owner)).toBe(true); }), ), ), diff --git a/packages/stack/src/managed-environment.integration.test.ts b/packages/stack/src/managed-environment.integration.test.ts index 787ca57939..6cd31fd6c2 100644 --- a/packages/stack/src/managed-environment.integration.test.ts +++ b/packages/stack/src/managed-environment.integration.test.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node"; import { it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import { afterEach, describe, expect } from "vitest"; import { cpSync, renameSync } from "node:fs"; import { join } from "node:path"; @@ -154,7 +154,7 @@ describe("managed environment identity", () => { updates: [{ ...update, to: update.from }], }; const result = yield* validateEnvironmentRepair(forged).pipe(Effect.exit); - expect(result._tag).toBe("Failure"); + expect(Exit.isFailure(result)).toBe(true); }), ).pipe(Effect.provide(gitLayer)), ); @@ -170,7 +170,7 @@ describe("managed environment identity", () => { if (report.state !== "needsRepair") throw new Error("expected duplicate checkout"); expect(report.reason).toBe("duplicate"); const repair = yield* validateEnvironmentRepair(report.repair).pipe(Effect.exit); - expect(repair._tag).toBe("Failure"); + expect(Exit.isFailure(repair)).toBe(true); const after = yield* discoverEnvironment(duplicatePath); expect(after.state).toBe("needsRepair"); }), diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index dcba0d8407..ec9cedf045 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Deferred, Effect, + Exit, Fiber, FileSystem, Layer, @@ -16,11 +17,11 @@ import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager, managedStackManagerLayer } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, ControlTransport } from "./managed/control.ts"; +import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts"; +import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; import { Stack } from "./Stack.ts"; import { DaemonServer } from "./DaemonServer.ts"; import { deleteManagedStack, stopManagedStack, updateManagedLaunch } from "./managed/lifecycle.ts"; @@ -32,6 +33,7 @@ import { freePorts, releaseLease, setupManagedManager, + startManagedStack, } from "../tests/helpers/managed-manager.ts"; const roots: Array = []; @@ -46,19 +48,20 @@ describe("managed stack lifecycle journeys", () => { const manager = yield* ManagedStackManager; const [apiPort, dbPort] = yield* freePorts(2); if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected interrupted-delete ports"); + throw new Error("expected free managed stack ports"); } const portDocument = exactCoreDocument(apiPort, dbPort); const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const owner = yield* acquireControl({ stackId }); - if (owner._tag !== "Owned") throw new Error("expected stack control ownership"); - const started = yield* manager.startStack({ + if (!isControlOwnership(owner)) throw new Error("expected stack control ownership"); + const started = yield* startManagedStack(manager, { workspacePath: workspace, stackName: "default", portDocument, ownership: owner, lifecycle: "running", + launch: { mode: "native", versions: {} }, }); yield* releaseLease(started); yield* owner.close; @@ -67,13 +70,12 @@ describe("managed stack lifecycle journeys", () => { workspacePath: workspace, stackName: "default", launch: { - mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [], }, }; const updated = yield* updateManagedLaunch(input); - expect(updated.launch).toEqual(input.launch); + expect(updated.launch).toEqual({ mode: "native", ...input.launch }); yield* stopManagedStack(input); const stopped = yield* manager.inspectStack(stackId); @@ -89,6 +91,56 @@ describe("managed stack lifecycle journeys", () => { ); }); + it.live("preserves launch mode while updating metadata without a runtime owner", () => { + const { layer, workspace } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const [apiPort, dbPort] = yield* freePorts(2); + if (apiPort === undefined || dbPort === undefined) { + throw new Error("expected free managed stack ports"); + } + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const owner = yield* acquireControl({ stackId }); + if (!isControlOwnership(owner)) throw new Error("expected stack control ownership"); + const started = yield* startManagedStack(manager, { + workspacePath: workspace, + stackName: "default", + portDocument: exactCoreDocument(apiPort, dbPort), + ownership: owner, + lifecycle: "running", + }); + yield* releaseLease(started); + yield* owner.close; + + const updated = yield* updateManagedLaunch({ + workspacePath: workspace, + stackName: "default", + launch: { + versions: { postgres: "17.6.1" }, + excludedServices: ["studio"], + lastNotifiedUpdateFingerprint: "fingerprint", + }, + }); + + expect(updated.launch).toEqual({ + mode: "native", + versions: { postgres: "17.6.1" }, + excludedServices: ["studio"], + lastNotifiedUpdateFingerprint: "fingerprint", + }); + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + Effect.provide(httpTransportClientLayer), + ); + }); + it.live("stops an owner whose document is still starting", () => { const { layer, workspace } = setup(); return Effect.scoped( @@ -97,8 +149,8 @@ describe("managed stack lifecycle journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const owner = yield* acquireControl({ stackId }); - if (owner._tag !== "Owned") throw new Error("expected ownership"); - const started = yield* manager.startStack({ + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + const started = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: owner, @@ -197,15 +249,18 @@ describe("managed stack lifecycle journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const owner = yield* acquireControl({ stackId }); - if (owner._tag !== "Owned") throw new Error("expected ownership"); - const initial = yield* manager.startStack({ + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + const initial = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: owner, lifecycle: "running", + launch: { mode: "native", versions: {} }, }); yield* releaseLease(initial); - const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" } }; + const launch = { + versions: { postgres: "17.6.1" }, + }; gate.enabled = true; const launchFiber = yield* Effect.forkScoped( manager.updateLaunch(owner, { stackId, launch }), @@ -231,7 +286,7 @@ describe("managed stack lifecycle journeys", () => { yield* Fiber.join(stopFiber); const final = yield* manager.inspectStack(stackId); expect(final?.lifecycle).toBe("stopped"); - expect(final?.launch).toEqual(launch); + expect(final?.launch).toEqual({ mode: "native", ...launch }); }), ).pipe( Effect.provide(managerLayer), @@ -250,8 +305,8 @@ describe("managed stack lifecycle journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const previousOwner = yield* acquireControl({ stackId }); - if (previousOwner._tag !== "Owned") throw new Error("expected ownership"); - const starting = yield* manager.startStack({ + if (!isControlOwnership(previousOwner)) throw new Error("expected ownership"); + const starting = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: previousOwner, @@ -260,8 +315,8 @@ describe("managed stack lifecycle journeys", () => { yield* releaseLease(starting); yield* previousOwner.close; const nextOwner = yield* acquireControl({ stackId }); - if (nextOwner._tag !== "Owned") throw new Error("expected reattached ownership"); - const recovered = yield* manager.startStack({ + if (!isControlOwnership(nextOwner)) throw new Error("expected reattached ownership"); + const recovered = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: { activeFields: [], @@ -292,8 +347,8 @@ describe("managed stack lifecycle journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const owner = yield* acquireControl({ stackId }); - if (owner._tag !== "Owned") throw new Error("expected ownership"); - const running = yield* manager.startStack({ + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + const running = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: owner, @@ -351,8 +406,8 @@ describe("managed stack lifecycle journeys", () => { const originalEnvironment = yield* ensureEnvironment(workspace); const originalStackId = deriveStackId(originalEnvironment.identity, "default"); const originalOwner = yield* acquireControl({ stackId: originalStackId }); - if (originalOwner._tag !== "Owned") throw new Error("expected original ownership"); - const original = yield* manager.startStack({ + if (!isControlOwnership(originalOwner)) throw new Error("expected original ownership"); + const original = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: originalOwner, @@ -365,8 +420,8 @@ describe("managed stack lifecycle journeys", () => { const copiedEnvironment = yield* ensureEnvironment(copied); const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); const copiedOwner = yield* acquireControl({ stackId: copiedStackId }); - if (copiedOwner._tag !== "Owned") throw new Error("expected copied ownership"); - const copiedStack = yield* manager.startStack({ + if (!isControlOwnership(copiedOwner)) throw new Error("expected copied ownership"); + const copiedStack = yield* startManagedStack(manager, { workspacePath: copied, portDocument: automaticDocument(), ownership: copiedOwner, @@ -379,8 +434,8 @@ describe("managed stack lifecycle journeys", () => { yield* Deferred.await(markerSwapped); yield* copiedOwner.close; const result = yield* Fiber.join(deleting).pipe(Effect.exit); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { expect(Cause.squash(result.cause)).toMatchObject({ _tag: "InvalidManagedIdentityError", }); @@ -431,8 +486,8 @@ describe("managed stack lifecycle journeys", () => { const originalEnvironment = yield* ensureEnvironment(workspace); const originalStackId = deriveStackId(originalEnvironment.identity, "default"); const originalOwner = yield* acquireControl({ stackId: originalStackId }); - if (originalOwner._tag !== "Owned") throw new Error("expected original ownership"); - const original = yield* manager.startStack({ + if (!isControlOwnership(originalOwner)) throw new Error("expected original ownership"); + const original = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: originalOwner, @@ -445,8 +500,8 @@ describe("managed stack lifecycle journeys", () => { const copiedEnvironment = yield* ensureEnvironment(copied); const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); const copiedOwner = yield* acquireControl({ stackId: copiedStackId }); - if (copiedOwner._tag !== "Owned") throw new Error("expected copied ownership"); - const copiedStack = yield* manager.startStack({ + if (!isControlOwnership(copiedOwner)) throw new Error("expected copied ownership"); + const copiedStack = yield* startManagedStack(manager, { workspacePath: copied, portDocument: automaticDocument(), ownership: copiedOwner, @@ -459,8 +514,8 @@ describe("managed stack lifecycle journeys", () => { yield* Deferred.await(markerSwapped); yield* copiedOwner.close; const result = yield* Fiber.join(stopping).pipe(Effect.exit); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { expect(Cause.squash(result.cause)).toMatchObject({ _tag: "InvalidManagedIdentityError", }); @@ -485,8 +540,8 @@ describe("managed stack lifecycle journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const owner = yield* acquireControl({ stackId }); - if (owner._tag !== "Owned") throw new Error("expected ownership"); - const started = yield* manager.startStack({ + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + const started = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership: owner, @@ -494,7 +549,7 @@ describe("managed stack lifecycle journeys", () => { }); yield* started.lease.releaseAll; - const documentPath = managedStackDocumentPath(stateRoot, stackId); + const documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); rmSync(documentPath); mkdirSync(documentPath); mkdirSync(join(documentPath, "nested")); @@ -504,7 +559,8 @@ describe("managed stack lifecycle journeys", () => { outcome: "removed", stackId, }); - expect(existsSync(managedStackPaths(stateRoot, stackId).root)).toBe(false); + const stackRoot = (yield* managedStackPathsEffect(stateRoot, stackId)).root; + expect(existsSync(stackRoot)).toBe(false); }), ).pipe( Effect.provide(layer), @@ -528,22 +584,23 @@ describe("managed stack lifecycle journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const previousOwner = yield* acquireControl({ stackId }); - if (previousOwner._tag !== "Owned") throw new Error("expected ownership"); - const previous = yield* manager.startStack({ + if (!isControlOwnership(previousOwner)) throw new Error("expected ownership"); + const previous = yield* startManagedStack(manager, { workspacePath: workspace, portDocument, ownership: previousOwner, }); yield* releaseLease(previous); - const dataPath = join(managedStackPaths(stateRoot, stackId).data, "orphaned"); - mkdirSync(managedStackPaths(stateRoot, stackId).data, { recursive: true }); + const stackPaths = yield* managedStackPathsEffect(stateRoot, stackId); + const dataPath = join(stackPaths.data, "orphaned"); + mkdirSync(stackPaths.data, { recursive: true }); writeFileSync(dataPath, "stale"); yield* manager.recordLifecycle(previousOwner, { stackId, lifecycle: "deleting" }); yield* previousOwner.close; const nextOwner = yield* acquireControl({ stackId }); - if (nextOwner._tag !== "Owned") throw new Error("expected recovered ownership"); - const restarted = yield* manager.startStack({ + if (!isControlOwnership(nextOwner)) throw new Error("expected recovered ownership"); + const restarted = yield* startManagedStack(manager, { workspacePath: workspace, portDocument, ownership: nextOwner, diff --git a/packages/stack/src/managed-manager-ports.integration.test.ts b/packages/stack/src/managed-manager-ports.integration.test.ts index a1676de2e3..3d6aadc528 100644 --- a/packages/stack/src/managed-manager-ports.integration.test.ts +++ b/packages/stack/src/managed-manager-ports.integration.test.ts @@ -8,7 +8,12 @@ import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager } from "./managed/manager.ts"; import { ManagedExactPortOccupiedError } from "./managed/model.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, CONTROL_PORT_RANGE, controlEndpoint } from "./managed/control.ts"; +import { + acquireControl, + CONTROL_PORT_RANGE, + controlEndpoint, + isControlOwnership, +} from "./managed/control.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { reservePortSet } from "./PortAllocator.ts"; import { @@ -24,6 +29,7 @@ import { listenExternal, releaseLease, setupManagedManager, + startManagedStack, startWithOwner, } from "../tests/helpers/managed-manager.ts"; @@ -38,14 +44,14 @@ describe("managed stack ports journeys", () => { Effect.gen(function* () { const manager = yield* ManagedStackManager; const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); - const first = yield* manager.startStack({ + if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); + const first = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: { activeFields: ["apiPort", "dbPort"], document: {} }, ownership, }); yield* releaseLease(first); - const second = yield* manager.startStack({ + const second = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: { activeFields: [], @@ -74,8 +80,8 @@ describe("managed stack ports journeys", () => { const manager = yield* ManagedStackManager; const apiPort = yield* freePort(); const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (ownership._tag !== "Owned") throw new Error("expected ownership"); - const started = yield* manager.startStack({ + if (!isControlOwnership(ownership)) throw new Error("expected ownership"); + const started = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: { activeFields: ["apiPort", "dbPort", "authPort"], @@ -151,9 +157,9 @@ describe("managed stack ports journeys", () => { Effect.gen(function* () { const manager = yield* ManagedStackManager; const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (ownership._tag !== "Owned") throw new Error("expected stack ownership"); + if (!isControlOwnership(ownership)) throw new Error("expected stack ownership"); const port = ownership.endpoint.port === 15_432 ? 15_433 : 15_432; - const started = yield* manager.startStack({ + const started = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: exactDocument("apiPort", port), ownership, @@ -182,8 +188,8 @@ describe("managed stack ports journeys", () => { stackId: otherStackId, ownership: otherOwnership, } = yield* acquireWorkspaceControl(base, "other"); - if (otherOwnership._tag !== "Owned") throw new Error("expected other stack ownership"); - const other = yield* manager.startStack({ + if (!isControlOwnership(otherOwnership)) throw new Error("expected other stack ownership"); + const other = yield* startManagedStack(manager, { workspacePath: otherWorkspace, portDocument: automaticDocument(), ownership: otherOwnership, @@ -194,14 +200,12 @@ describe("managed stack ports journeys", () => { const otherEndpoint = yield* controlEndpoint(otherStackId); const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (ownership._tag !== "Owned") throw new Error("expected stack ownership"); - const rejected = yield* manager - .startStack({ - workspacePath: workspace, - portDocument: exactDocument("apiPort", otherEndpoint.port), - ownership, - }) - .pipe(Effect.exit); + if (!isControlOwnership(ownership)) throw new Error("expected stack ownership"); + const rejected = yield* startManagedStack(manager, { + workspacePath: workspace, + portDocument: exactDocument("apiPort", otherEndpoint.port), + ownership, + }).pipe(Effect.exit); expect(Exit.isFailure(rejected)).toBe(true); if (Exit.isFailure(rejected)) { @@ -234,14 +238,12 @@ describe("managed stack ports journeys", () => { expect(external.listening).toBe(true); const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (ownership._tag !== "Owned") throw new Error("expected stack ownership"); - const rejected = yield* manager - .startStack({ - workspacePath: workspace, - portDocument: exactDocument("apiPort", port), - ownership, - }) - .pipe(Effect.exit); + if (!isControlOwnership(ownership)) throw new Error("expected stack ownership"); + const rejected = yield* startManagedStack(manager, { + workspacePath: workspace, + portDocument: exactDocument("apiPort", port), + ownership, + }).pipe(Effect.exit); expect(Exit.isFailure(rejected)).toBe(true); if (Exit.isFailure(rejected)) { @@ -277,14 +279,14 @@ describe("managed stack ports journeys", () => { stackId, ownership, } = yield* acquireWorkspaceControl(root, "first"); - if (ownership._tag !== "Owned") throw new Error("expected ownership"); - const exact = yield* manager.startStack({ + if (!isControlOwnership(ownership)) throw new Error("expected ownership"); + const exact = yield* startManagedStack(manager, { workspacePath: firstWorkspace, portDocument: exactCoreDocument(apiPort, dbPort), ownership, }); yield* releaseLease(exact); - const first = yield* manager.startStack({ + const first = yield* startManagedStack(manager, { workspacePath: firstWorkspace, portDocument: automaticRuntimeDocument(), ownership, @@ -335,13 +337,11 @@ describe("managed stack ports journeys", () => { const external = yield* reservePortSet([ { field: "apiPort", selection: { kind: "exact", port: firstPort } }, ]); - const restart = yield* manager - .startStack({ - workspacePath: firstWorkspace, - portDocument: automaticRuntimeDocument(), - ownership, - }) - .pipe(Effect.exit); + const restart = yield* startManagedStack(manager, { + workspacePath: firstWorkspace, + portDocument: automaticRuntimeDocument(), + ownership, + }).pipe(Effect.exit); expect(Exit.isFailure(restart)).toBe(true); if (Exit.isFailure(restart)) { expect(Cause.squash(restart.cause)).toMatchObject({ @@ -351,8 +351,8 @@ describe("managed stack ports journeys", () => { yield* external.releaseAll; const { workspace: secondWorkspace, ownership: secondOwnership } = yield* acquireWorkspaceControl(root, "second"); - if (secondOwnership._tag !== "Owned") throw new Error("expected second ownership"); - const second = yield* manager.startStack({ + if (!isControlOwnership(secondOwnership)) throw new Error("expected second ownership"); + const second = yield* startManagedStack(manager, { workspacePath: secondWorkspace, portDocument: automaticRuntimeDocument(), ownership: secondOwnership, @@ -385,8 +385,8 @@ describe("managed stack ports journeys", () => { stackId, ownership: initialOwnership, } = yield* acquireWorkspaceControl(root); - if (initialOwnership._tag !== "Owned") throw new Error("expected ownership"); - const running = yield* manager.startStack({ + if (!isControlOwnership(initialOwnership)) throw new Error("expected ownership"); + const running = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: exactCoreDocument(original, dbPort), ownership: initialOwnership, @@ -408,8 +408,8 @@ describe("managed stack ports journeys", () => { yield* manager.recordLifecycle(initialOwnership, { stackId, lifecycle: "stopped" }); yield* initialOwnership.close; const ownership = yield* acquireControl({ stackId }); - if (ownership._tag !== "Owned") throw new Error("expected ownership"); - const stopped = yield* manager.startStack({ + if (!isControlOwnership(ownership)) throw new Error("expected ownership"); + const stopped = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: exactCoreDocument(changed, dbPort), ownership, @@ -436,14 +436,14 @@ describe("managed stack ports journeys", () => { const manager = yield* ManagedStackManager; const port = yield* freePort(); const { workspace, ownership } = yield* acquireWorkspaceControl(root); - if (ownership._tag !== "Owned") throw new Error("expected ownership"); - const first = yield* manager.startStack({ + if (!isControlOwnership(ownership)) throw new Error("expected ownership"); + const first = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: exactDocument("studioPort", port), ownership, }); yield* releaseLease(first); - const disabled = yield* manager.startStack({ + const disabled = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: { activeFields: [], disabledFields: ["studioPort"], document: {} }, ownership, @@ -452,7 +452,7 @@ describe("managed stack ports journeys", () => { disabled.stack.ports.filter((assignment) => assignment.key === "studio.port"), ).toEqual([{ key: "studio.port", port, intent: "exact" }]); yield* releaseLease(disabled); - const removed = yield* manager.startStack({ + const removed = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: { activeFields: [], document: {} }, ownership, diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index 1ff6a4013a..01e3c82482 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, ControlTransport } from "./managed/control.ts"; +import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { listStacks as listStackSummaries, resolveStackSummary } from "./discovery.ts"; @@ -25,6 +25,7 @@ import { automaticDocument, cleanupRoots, setupManagedManager, + startManagedStack, startWithOwner, } from "../tests/helpers/managed-manager.ts"; @@ -41,8 +42,8 @@ describe("managed stack projects journeys", () => { const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); const ownership = yield* acquireControl({ stackId }); - if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); - const initial = yield* manager.startStack({ + if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); + const initial = yield* startManagedStack(manager, { workspacePath: workspace, stackName: "default", portDocument: automaticDocument(), @@ -71,15 +72,13 @@ describe("managed stack projects journeys", () => { }); } - const copiedStart = yield* manager - .startStack({ - workspacePath: copied, - stackName: "default", - portDocument: automaticDocument(), - ownership, - lifecycle: "stopped", - }) - .pipe(Effect.exit); + const copiedStart = yield* startManagedStack(manager, { + workspacePath: copied, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }).pipe(Effect.exit); expect(Exit.isFailure(copiedStart)).toBe(true); if (Exit.isFailure(copiedStart)) { const error = Cause.squash(copiedStart.cause); @@ -100,7 +99,7 @@ describe("managed stack projects journeys", () => { const movedDiscovery = yield* manager.discoverWorkspace(copied); expect(movedDiscovery.state).toBe("ready"); writeFileSync(workspace, "stale workspace path"); - const moved = yield* manager.startStack({ + const moved = yield* startManagedStack(manager, { workspacePath: copied, stackName: "default", portDocument: automaticDocument(), @@ -197,7 +196,7 @@ describe("managed stack projects journeys", () => { ready: true, }, }); - if (owner._tag !== "Owned") throw new Error("status probe took control ownership"); + if (!isControlOwnership(owner)) throw new Error("status probe took control ownership"); yield* Deferred.succeed(continueRead, void 0); const summary = yield* Fiber.join(summaryFiber); expect(summary.running).toBe(true); diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index 9ccfffc7e6..ba6ff7227a 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -24,10 +24,10 @@ import { managedStackManagerLayer, } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, ControlTransport } from "./managed/control.ts"; +import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; -import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts"; +import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; import { Stack } from "./Stack.ts"; import { DaemonServer } from "./DaemonServer.ts"; import { makeRepository } from "../tests/helpers/git-workspace.ts"; @@ -39,6 +39,7 @@ import { controlStack, releaseLease, setupManagedManager, + startManagedStack, startWithOwner, } from "../tests/helpers/managed-manager.ts"; @@ -55,7 +56,7 @@ const acquireIsolatedCollisionOwner = () => Effect.timeout("5 seconds"), Effect.exit, ); - if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { + if (Exit.isSuccess(acquisition) && isControlOwnership(acquisition.value)) { return { collidingStackId, ownership: acquisition.value }; } } @@ -72,7 +73,7 @@ const acquireIsolatedStackOwner = (workspacePath: string) => Effect.timeout("5 seconds"), Effect.exit, ); - if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { + if (Exit.isSuccess(acquisition) && isControlOwnership(acquisition.value)) { return { stackName, ownership: acquisition.value }; } } @@ -88,7 +89,7 @@ const startWithIsolatedOwner = ( Effect.scoped( Effect.gen(function* () { const { stackName, ownership } = yield* acquireIsolatedStackOwner(workspacePath); - const result = yield* manager.startStack({ + const result = yield* startManagedStack(manager, { workspacePath, stackName, portDocument, @@ -145,7 +146,7 @@ describe("managed stack recovery journeys", () => { cpSync(workspace, copied, { recursive: true }); const stackId = deriveStackId(environment.identity, "default"); const ownership = yield* acquireControl({ stackId }); - if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); const readFiber = yield* Effect.forkScoped( manager.readStack({ workspacePath: copied, portDocument: automaticDocument() }), @@ -153,7 +154,7 @@ describe("managed stack recovery journeys", () => { yield* Deferred.await(readStarted); const startFiber = yield* Effect.forkScoped( Effect.gen(function* () { - const started = yield* manager.startStack({ + const started = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), ownership, @@ -188,24 +189,34 @@ describe("managed stack recovery journeys", () => { const baseTransport = yield* ControlTransport; const repairRead = yield* Deferred.make(); let repairEndpointUrl: string | undefined; + let repairId: string | undefined; const observedTransport = Layer.succeed(ControlTransport, { ...baseTransport, - read: (endpoint) => - Effect.gen(function* () { - if (endpoint.url === repairEndpointUrl) { - yield* Deferred.succeed(repairRead, void 0); - } - return yield* baseTransport.read(endpoint); - }), + read: (endpoint) => { + const ownerId = repairId; + if (endpoint.url !== repairEndpointUrl || ownerId === undefined) { + return baseTransport.read(endpoint); + } + return Deferred.succeed(repairRead, void 0).pipe( + Effect.andThen( + Effect.succeed({ + protocolVersion: 1, + ownershipId: ownerId, + state: "running" as const, + ready: true, + }), + ), + ); + }, }); return yield* Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; const environment = yield* ensureEnvironment(workspace); - const repairId = deriveRepairOwnershipId(environment.identity); + repairId = deriveRepairOwnershipId(environment.identity); const repairOwner = yield* acquireControl({ stackId: repairId }); - if (repairOwner._tag !== "Owned") throw new Error("expected repair ownership"); + if (!isControlOwnership(repairOwner)) throw new Error("expected repair ownership"); repairEndpointUrl = repairOwner.endpoint.url; const repairDaemon = ManagedRuntime.make( DaemonServer.layerWithShutdown(Effect.void, repairOwner.ownerStatus).pipe( @@ -216,17 +227,16 @@ describe("managed stack recovery journeys", () => { yield* Effect.promise(() => repairDaemon.runPromise(DaemonServer)); const stackOwner = yield* acquireIsolatedStackOwner(workspace); const stackId = deriveStackId(environment.identity, stackOwner.stackName); - const startFiber = yield* manager - .startStack({ - workspacePath: workspace, - stackName: stackOwner.stackName, - portDocument: automaticDocument(), - ownership: stackOwner.ownership, - }) - .pipe(Effect.forkScoped); + const startFiber = yield* startManagedStack(manager, { + workspacePath: workspace, + stackName: stackOwner.stackName, + portDocument: automaticDocument(), + ownership: stackOwner.ownership, + }).pipe(Effect.forkScoped); yield* Deferred.await(repairRead).pipe(Effect.timeout("30 seconds")); expect(yield* manager.inspectStack(stackId)).toBeUndefined(); yield* repairOwner.close; + repairEndpointUrl = undefined; yield* Effect.promise(() => repairDaemon.dispose()); const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("60 seconds")); expect(started.stack.id).toBe(stackId); @@ -350,7 +360,7 @@ describe("managed stack recovery journeys", () => { expect(Exit.isFailure(deleteBeforeRepair)).toBe(true); const blockedId = [originalId, secondaryId].sort().at(-1); if (blockedId === undefined) throw new Error("expected affected stack"); - const blockedRoot = managedStackPaths(stateRoot, blockedId).root; + const blockedRoot = (yield* managedStackPathsEffect(stateRoot, blockedId)).root; blockedWrites.root = blockedRoot; const failed = yield* manager.repairWorkspace(discovery.repair).pipe(Effect.exit); blockedWrites.root = undefined; @@ -393,8 +403,12 @@ describe("managed stack recovery journeys", () => { const stack = yield* startWithOwner(manager, workspace, automaticDocument()); yield* releaseLease(stack); const corruptId = "f".repeat(64); - mkdirSync(managedStackPaths(stateRoot, corruptId).root, { recursive: true }); - writeFileSync(managedStackDocumentPath(stateRoot, corruptId), "not-json"); + const corruptPaths = yield* managedStackPathsEffect(stateRoot, corruptId); + const corruptDocumentPath = yield* managedStackDocumentPathEffect(stateRoot, corruptId); + yield* Effect.sync(() => { + mkdirSync(corruptPaths.root, { recursive: true }); + writeFileSync(corruptDocumentPath, "not-json"); + }); const listings = yield* manager.listStacks(); expect(listings).toEqual( expect.arrayContaining([ diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index 20de9c108e..d9c2778262 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -1,14 +1,18 @@ +import { Effect } from "effect"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { assertManagedUuid } from "./managed/ids.ts"; +import { validateManagedUuid } from "./managed/ids.ts"; import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./managed/model.ts"; import * as managedPathsModule from "./managed/paths.ts"; import { - assertManagedStackRoot, - managedStackPaths, - resolveManagedStateRoot, + assertManagedStackRootEffect, + managedStackPathsEffect, + resolveManagedStateRootEffect, } from "./managed/paths.ts"; +const run = (effect: Effect.Effect): A => Effect.runSync(effect); +const failureOf = (effect: Effect.Effect): E => Effect.runSync(Effect.flip(effect)); + describe("managed paths", () => { it("does not expose the removed SQLite registry path", () => { expect(managedPathsModule).not.toHaveProperty("managedRegistryPath"); @@ -21,129 +25,159 @@ describe("managed paths", () => { ["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); + expect(failureOf(validateManagedUuid(value, "test id"))).toBeInstanceOf( + InvalidManagedIdentityError, + ); }); it("isolates managed records beneath SUPABASE_HOME", () => { expect( - resolveManagedStateRoot({ - env: { SUPABASE_HOME: "/configured/supabase" }, - homeDir: "/home/user", - platform: "linux", - }), + run( + resolveManagedStateRootEffect({ + 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", - }), + run( + resolveManagedStateRootEffect({ + 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", - }), + run( + resolveManagedStateRootEffect({ + 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", - }), + run( + resolveManagedStateRootEffect({ + 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", - }), + run( + resolveManagedStateRootEffect({ + 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", - }), + run(resolveManagedStateRootEffect({ env: {}, homeDir: "/home/user", platform: "linux" })), ).toBe("/home/user/.local/state/supabase/managed"); expect( - resolveManagedStateRoot({ - env: { LOCALAPPDATA: "" }, - homeDir: "C:\\Users\\user", - platform: "win32", - }), + run(resolveManagedStateRootEffect({ env: {}, homeDir: "/Users/user", platform: "darwin" })), + ).toBe("/Users/user/Library/Application Support/supabase/managed"); + expect( + run( + resolveManagedStateRootEffect({ + env: { XDG_STATE_HOME: "" }, + homeDir: "/home/user", + platform: "linux", + }), + ), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + run( + resolveManagedStateRootEffect({ + 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( + expect(run(resolveManagedStateRootEffect({ stateRoot: "relative/managed" }))).toBe( resolve("relative/managed"), ); - expect(resolveManagedStateRoot({ stateRoot: "/absolute/managed" })).toBe("/absolute/managed"); + expect(run(resolveManagedStateRootEffect({ stateRoot: "/absolute/managed" }))).toBe( + "/absolute/managed", + ); expect( - resolveManagedStateRoot({ - env: { SUPABASE_HOME: "relative/supabase" }, - homeDir: "/home/user", - platform: "linux", - }), + run( + resolveManagedStateRootEffect({ + 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", - }), + run( + resolveManagedStateRootEffect({ + 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( + failureOf( + resolveManagedStateRootEffect({ + stateRoot, + env: {}, + homeDir: "/home/user", + platform: "linux", + }), + ), + ).toBeInstanceOf(UnsafeManagedStackPathError); } - expect(() => - resolveManagedStateRoot({ - stateRoot: "", - env: { SUPABASE_HOME: "/configured/supabase" }, - homeDir: "/home/user", - platform: "linux", - }), - ).toThrow(UnsafeManagedStackPathError); + expect( + failureOf( + resolveManagedStateRootEffect({ + stateRoot: "", + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ), + ).toBeInstanceOf(UnsafeManagedStackPathError); }); it("names the blank root it refused instead of an empty message tail", () => { - expect(() => resolveManagedStateRoot({ stateRoot: "\t" })).toThrow(/"\\t"/); + expect(failureOf(resolveManagedStateRootEffect({ stateRoot: "\t" }))).toMatchObject({ + path: "\t", + message: 'Refusing a blank managed state root: "\\t"', + }); }); it("trims surrounding whitespace from an explicit state root", () => { - expect(resolveManagedStateRoot({ stateRoot: " /absolute/managed " })).toBe( + expect(run(resolveManagedStateRootEffect({ 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({ + expect(run(managedStackPathsEffect("/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", @@ -152,11 +186,17 @@ describe("managed paths", () => { }); it("rejects non-UUID IDs and stack paths that do not match the derived root", () => { - expect(() => managedStackPaths("/state", "../../tmp/escaped")).toThrow( + expect(failureOf(managedStackPathsEffect("/state", "../../tmp/escaped"))).toBeInstanceOf( InvalidManagedIdentityError, ); - expect(() => - assertManagedStackRoot("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", "/tmp/escaped"), - ).toThrow(UnsafeManagedStackPathError); + expect( + failureOf( + assertManagedStackRootEffect( + "/state", + "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", + "/tmp/escaped", + ), + ), + ).toBeInstanceOf(UnsafeManagedStackPathError); }); }); diff --git a/packages/stack/src/managed-store.integration.test.ts b/packages/stack/src/managed-store.integration.test.ts index 3154555e76..2434feb010 100644 --- a/packages/stack/src/managed-store.integration.test.ts +++ b/packages/stack/src/managed-store.integration.test.ts @@ -1,11 +1,11 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, PlatformError, Predicate } from "effect"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; -import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts"; +import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; import { makeStackStore } from "./managed/store.ts"; import type { ManagedStackDocument } from "./managed/document.ts"; @@ -100,6 +100,7 @@ const document = (overrides: Partial = {}): ManagedStackDo { key: "db.port", port: 54322, intent: "automatic" }, ], lifecycle: "stopped", + launch: { mode: "native", versions: {} }, createdAt: "2026-08-16T00:00:00.000Z", updatedAt: "2026-08-16T00:00:00.000Z", ...overrides, @@ -108,9 +109,9 @@ const document = (overrides: Partial = {}): ManagedStackDo const makeTempStackStore = (stateRoot = makeRoot()) => makeStackStore(stateRoot); const writeRawStackDocument = (stateRoot: string, stackId: string, content: string): void => { - const stackRoot = managedStackPaths(stateRoot, stackId).root; + const stackRoot = Effect.runSync(managedStackPathsEffect(stateRoot, stackId)).root; mkdirSync(stackRoot, { recursive: true }); - writeFileSync(managedStackDocumentPath(stateRoot, stackId), content); + writeFileSync(Effect.runSync(managedStackDocumentPathEffect(stateRoot, stackId)), content); }; describe("managed stack document store", () => { @@ -139,6 +140,7 @@ describe("managed stack document store", () => { document({ launch: { mode: "docker", + containerRuntime: "docker", versions: { postgres: "17.6.1" }, excludedServices: ["studio", "analytics"], lastNotifiedUpdateFingerprint: "fingerprint", @@ -147,6 +149,7 @@ describe("managed stack document store", () => { ); expect((yield* store.read(STACK_ID))?.launch).toEqual({ mode: "docker", + containerRuntime: "docker", versions: { postgres: "17.6.1" }, excludedServices: ["studio", "analytics"], lastNotifiedUpdateFingerprint: "fingerprint", @@ -154,6 +157,60 @@ describe("managed stack document store", () => { }).pipe(Effect.provide(filesystemLayer)), ); + it.live("rejects unknown launch modes as invalid managed documents", () => + Effect.gen(function* () { + const store = yield* makeTempStackStore(); + writeRawStackDocument( + store.stateRoot, + STACK_ID, + JSON.stringify({ ...document(), launch: { mode: "auto", versions: {} } }), + ); + + const exit = yield* store.read(STACK_ID).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect( + Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackDocumentError"), + ).toBe(true); + } + }).pipe(Effect.provide(filesystemLayer)), + ); + + it.live("rejects managed documents without a concrete launch selection", () => + Effect.gen(function* () { + const store = yield* makeTempStackStore(); + const { launch: _launch, ...withoutLaunch } = document(); + writeRawStackDocument(store.stateRoot, STACK_ID, JSON.stringify(withoutLaunch)); + + const exit = yield* store.read(STACK_ID).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect( + Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackDocumentError"), + ).toBe(true); + } + }).pipe(Effect.provide(filesystemLayer)), + ); + + it.live("rejects an incomplete Docker launch as an invalid managed document", () => + Effect.gen(function* () { + const store = yield* makeTempStackStore(); + writeRawStackDocument( + store.stateRoot, + STACK_ID, + JSON.stringify({ ...document(), launch: { mode: "docker", versions: {} } }), + ); + + const exit = yield* store.read(STACK_ID).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect( + Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackDocumentError"), + ).toBe(true); + } + }).pipe(Effect.provide(filesystemLayer)), + ); + it.live("lists a corrupt stack beside healthy stacks", () => Effect.gen(function* () { const store = yield* makeTempStackStore(); @@ -170,7 +227,8 @@ describe("managed stack document store", () => { Effect.gen(function* () { const store = yield* makeTempStackStore(); yield* store.write(document({ id: HEALTHY_ID })); - mkdirSync(managedStackDocumentPath(store.stateRoot, CORRUPT_ID), { recursive: true }); + const corruptPath = yield* managedStackDocumentPathEffect(store.stateRoot, CORRUPT_ID); + yield* Effect.sync(() => mkdirSync(corruptPath, { recursive: true })); const listings = yield* store.list(); expect(listings).toEqual([ diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts index 51b1a65411..79cfbe8538 100644 --- a/packages/stack/src/managed.ts +++ b/packages/stack/src/managed.ts @@ -67,8 +67,9 @@ export type { AllocateManagedPortsRequest, ReadStackRequest, StartStackRequest, - ManagedStackLaunchUpdate, + ManagedStackLaunchUpdateRequest, } from "./managed/manager.ts"; +export type { ManagedStackLaunchUpdate } from "./managed/document.ts"; export { connectManagedStack, deleteManagedStack, diff --git a/packages/stack/src/managed/atomic-claim.integration.test.ts b/packages/stack/src/managed/atomic-claim.integration.test.ts new file mode 100644 index 0000000000..72d7d8cd36 --- /dev/null +++ b/packages/stack/src/managed/atomic-claim.integration.test.ts @@ -0,0 +1,124 @@ +import { NodeFileSystem } from "@effect/platform-node"; +import { it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, FileSystem, Layer, PlatformError } from "effect"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect } from "vitest"; +import { AtomicClaimUnsupportedError, claimFileAtomically } from "./atomic-claim.ts"; + +const roots: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const makeRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), "managed-claim-test-")); + roots.push(root); + return root; +}; + +const interruptibleLinkLayer = ( + started: Deferred.Deferred, + release: Deferred.Deferred, +) => + Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (fs) => { + let blockNextLink = true; + return { + ...fs, + link: (fromPath: string, toPath: string) => { + if (!blockNextLink) return fs.link(fromPath, toPath); + blockNextLink = false; + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(fs.link(fromPath, toPath)), + ); + }, + }; + }), + ).pipe(Layer.provide(NodeFileSystem.layer)); + +const unsupportedLinkLayer = (code: string) => + Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (fs) => ({ + ...fs, + link: (_fromPath: string, _toPath: string) => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "test", + method: "link", + description: "hard links are unavailable", + cause: { code }, + }), + ), + })), + ).pipe(Layer.provide(NodeFileSystem.layer)); + +describe("managed atomic claims", () => { + it.live("concurrent claimants publish exactly one complete marker", () => { + const target = join(makeRoot(), "identity.json"); + return Effect.gen(function* () { + const outcomes = yield* Effect.all( + Array.from({ length: 8 }, (_, index) => + claimFileAtomically(target, `winner-${index}\n`, { mode: 0o600 }), + ), + { concurrency: "unbounded" }, + ); + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(target); + expect(outcomes.filter((outcome) => outcome === "claimed")).toHaveLength(1); + expect(content).toMatch(/^winner-[0-7]\n$/); + }).pipe(Effect.provide(NodeFileSystem.layer)); + }); + + it.live("reports unsupported hard-link publication errors as typed failures", () => { + return Effect.gen(function* () { + for (const code of ["ENOTSUP", "ENOSYS", "EXDEV"] as const) { + const root = makeRoot(); + const unsupportedLayer = unsupportedLinkLayer(code); + + const failure = yield* Effect.flip( + claimFileAtomically(join(root, "identity.json"), "content\n").pipe( + Effect.provide(unsupportedLayer), + ), + ); + expect(failure).toBeInstanceOf(AtomicClaimUnsupportedError); + const entries = yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(root); + }).pipe(Effect.provide(unsupportedLayer)); + expect(entries).toEqual([]); + } + }); + }); + + it.live("cleans interrupted publication state so a later claim can retry", () => { + const root = makeRoot(); + const target = join(root, "identity.json"); + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const layer = interruptibleLinkLayer(started, release); + const first = yield* claimFileAtomically(target, "first\n").pipe( + Effect.provide(layer), + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(started); + yield* Fiber.interrupt(first); + yield* Deferred.succeed(release, undefined); + + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(target)).toBe(false); + expect(yield* fs.readDirectory(root)).toEqual([]); + + const retried = yield* claimFileAtomically(target, "second\n").pipe(Effect.provide(layer)); + expect(retried).toBe("claimed"); + expect(yield* fs.readFileString(target)).toBe("second\n"); + }).pipe(Effect.provide(NodeFileSystem.layer)); + }); +}); diff --git a/packages/stack/src/managed/atomic-claim.ts b/packages/stack/src/managed/atomic-claim.ts index 4e7b8ead30..d0c84222ba 100644 --- a/packages/stack/src/managed/atomic-claim.ts +++ b/packages/stack/src/managed/atomic-claim.ts @@ -1,73 +1,144 @@ import { randomUUID } from "node:crypto"; -import { link, unlink, writeFile } from "node:fs/promises"; -import { errorCode } from "./error-code.ts"; +import { Cause, Data, Effect, Exit, FileSystem, Option, PlatformError, Predicate } from "effect"; export type FileClaimOutcome = "claimed" | "already-exists"; +/** The hard-link publication protocol is unavailable on this filesystem. */ +export class AtomicClaimUnsupportedError extends Data.TaggedError("AtomicClaimUnsupportedError")<{ + readonly targetPath: string; + readonly message: string; +}> {} + export interface FileClaimOptions { /** Mode for the published file; defaults to the process umask. */ readonly mode?: number; - /** - * The hardlink step, overridable so a test can drive the hardlink-less - * fallback on a filesystem that does support hardlinks. - */ - readonly linkFile?: (existingPath: string, newPath: string) => Promise; } -const createExclusively = async ( +const isAlreadyExists = (error: PlatformError.PlatformError): boolean => + Predicate.isTagged(error.reason, "AlreadyExists"); + +const errorCode = (error: PlatformError.PlatformError): string | undefined => { + const cause = error.reason.cause; + if (cause !== null && typeof cause === "object" && "code" in cause) { + const code = cause.code; + return typeof code === "string" ? code : undefined; + } + return undefined; +}; + +const isHardLinkUnsupported = (error: PlatformError.PlatformError): boolean => { + const code = errorCode(error); + return ( + code === "EPERM" || + code === "ENOTSUP" || + code === "EOPNOTSUPP" || + code === "ENOSYS" || + code === "EXDEV" + ); +}; + +const unsupportedHardLink = ( targetPath: string, + error: PlatformError.PlatformError, +): Effect.Effect => + Effect.fail( + new AtomicClaimUnsupportedError({ + targetPath, + message: `Filesystem cannot atomically publish a managed claim (${error.message})`, + }), + ); + +const removeOwnedFile = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + fs.remove(path, { force: true }).pipe(Effect.ignore); + +/** + * Writes an owned file with an interruption-safe open handoff. Exclusive open + * and ownership recording stay masked as one region; only the subsequent write + * is restored, so a created pathname can never escape without cleanup ownership. + */ +const writeOwnedFile = ( + fs: FileSystem.FileSystem, + path: string, content: string, mode: number | undefined, -): Promise => { - try { - await writeFile(targetPath, content, { flag: "wx", mode }); - return "claimed"; - } catch (error: unknown) { - if (errorCode(error) === "EEXIST") { - return "already-exists"; +): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + let attempted = false; + let owned = false; + const result = yield* Effect.exit( + Effect.scoped( + Effect.gen(function* () { + // The surrounding mask covers open and this handoff assignment. + attempted = true; + const file = yield* fs.open(path, { flag: "wx", mode }); + owned = true; + yield* restore(file.writeAll(new TextEncoder().encode(content))); + }), + ), + ); + if (Exit.isSuccess(result)) return; + const error = Cause.findErrorOption(result.cause); + const interruptedBeforeOwnership = + attempted && + Cause.hasInterrupts(result.cause) && + (Option.isNone(error) || !isAlreadyExists(error.value)); + if (owned || interruptedBeforeOwnership) { + yield* removeOwnedFile(fs, path); + } + return yield* Effect.failCause(result.cause); + }), + ); + +const publish = ( + fs: FileSystem.FileSystem, + temporaryPath: string, + targetPath: string, +): Effect.Effect => + Effect.gen(function* () { + // A hard link publishes the complete, closed temp atomically and refuses an + // existing target. Unsupported links fail as typed PlatformError rather than + // falling back to a direct target write or an unprovable sidecar protocol. + const linked = yield* Effect.exit(fs.link(temporaryPath, targetPath)); + if (Exit.isSuccess(linked)) return "claimed" as const; + const linkError = Cause.findErrorOption(linked.cause); + if (Option.isNone(linkError)) return yield* Effect.failCause(linked.cause); + if (isAlreadyExists(linkError.value)) { + const readable = yield* Effect.exit(fs.readFile(targetPath)); + if (Exit.isSuccess(readable)) return "already-exists" as const; + return yield* Effect.failCause(readable.cause); } - throw error; - } -}; + if (isHardLinkUnsupported(linkError.value)) { + return yield* unsupportedHardLink(targetPath, linkError.value); + } + return yield* Effect.fail(linkError.value); + }); /** * Publishes `content` at `targetPath` unless a claimant got there first. * - * The content is written to a sibling temporary file and hardlinked into place, - * because `link` publishes the whole file in one step and refuses an existing - * target: writing `targetPath` directly could crash halfway and publish a - * partial claim, and testing for the file before writing it would lose the very - * race the claim exists to settle. Filesystems without hardlinks — exFAT, - * FAT32, some network mounts — refuse `link` with `EPERM` or `ENOTSUP`; those - * fall back to an exclusive create, which still settles the race but gives up - * the all-or-nothing publish. Any other failure is a real one and propagates. - * - * A `SIGKILL` between the temporary write and its removal strands a - * `.tmp.` sibling. Nothing ever reads those, so a stranded one is junk - * rather than a claim anybody can observe, and every attempt gets a fresh - * temporary path so a concurrent claimant cannot overwrite its source. + * Every claimant writes a unique sibling completely before publication. The + * hard-link publication is the sole publication primitive: it exposes only a + * complete temp and never overwrites a winner. Filesystems that reject hard + * links fail as a typed PlatformError after exact temporary cleanup. */ -export const claimFileAtomically = async ( +export const claimFileAtomically = ( targetPath: string, content: string, options: FileClaimOptions = {}, -): Promise => { - const linkFile = options.linkFile ?? link; - const temporaryPath = `${targetPath}.tmp.${randomUUID()}`; - await writeFile(temporaryPath, content, { mode: options.mode }); - try { - await linkFile(temporaryPath, targetPath); - return "claimed"; - } catch (error: unknown) { - const code = errorCode(error); - if (code === "EEXIST") { - return "already-exists"; - } - if (code !== "EPERM" && code !== "ENOTSUP") { - throw error; - } - return await createExclusively(targetPath, content, options.mode); - } finally { - await unlink(temporaryPath).catch(() => undefined); - } -}; +): Effect.Effect< + FileClaimOutcome, + PlatformError.PlatformError | AtomicClaimUnsupportedError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const temporaryPath = `${targetPath}.tmp.${randomUUID()}`; + + // Register exact temp cleanup before entering interruptible publication. + return yield* Effect.acquireUseRelease( + writeOwnedFile(fs, temporaryPath, content, options.mode).pipe(Effect.as(temporaryPath)), + () => publish(fs, temporaryPath, targetPath), + (ownedPath) => removeOwnedFile(fs, ownedPath), + ); + }); diff --git a/packages/stack/src/managed/control.ts b/packages/stack/src/managed/control.ts index 65efbdab0b..b36b63edf0 100644 --- a/packages/stack/src/managed/control.ts +++ b/packages/stack/src/managed/control.ts @@ -1,4 +1,4 @@ -import { Data, Deferred, Effect, Context, Ref, Result, Schedule, Schema } from "effect"; +import { Data, Deferred, Effect, Context, Predicate, Ref, Result, Schedule, Schema } from "effect"; import { HttpServer } from "effect/unstable/http"; import { ControlOwnerStatusSchema, @@ -137,6 +137,14 @@ export interface ControlAttached { export type ControlAcquisition = ControlOwnership | ControlAttached; +export const isControlOwnership = ( + acquisition: ControlAcquisition, +): acquisition is ControlOwnership => Predicate.isTagged(acquisition, "Owned"); + +export const isControlAttached = ( + acquisition: ControlAcquisition, +): acquisition is ControlAttached => Predicate.isTagged(acquisition, "Attached"); + const invalidId = (ownershipId: string): Effect.Effect => Effect.fail(new InvalidControlOwnershipIdError({ ownershipId })); @@ -339,12 +347,17 @@ const scanForOwner = ( candidates: ReadonlyArray, ownershipId: string, transport: ControlTransportShape, -): Effect.Effect => +): Effect.Effect< + ControlEndpoint | undefined, + ControlProtocolMismatchError | ControlTransportError +> => Effect.gen(function* () { for (const endpoint of candidates) { const found = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe( Effect.map(() => true), - Effect.catchTag("ControlTransportError", () => Effect.succeed(false)), + Effect.catchTag("ControlTransportError", (cause) => + cause.reason === "unreachable" ? Effect.succeed(false) : Effect.fail(cause), + ), Effect.catchTag("ControlProtocolError", () => Effect.succeed(false)), Effect.catchTag("ControlAddressConflictError", () => Effect.succeed(false)), ); @@ -395,7 +408,7 @@ const acquireAtCandidates = ( endpoint, () => Ref.getUnsafe(statusRef), () => { - Effect.runSync(Deferred.succeed(stopRequested, void 0)); + Deferred.doneUnsafe(stopRequested, Effect.succeed(undefined)); }, ) .pipe(Effect.result); @@ -460,7 +473,7 @@ const acquireAtCandidates = ( // consume the 500 ms transport timeout, and a count-based budget would // stretch a single acquire far past the parent's startup handshake. schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "1500 millis" })), - while: (error) => error._tag === "ControlUnavailableError", + while: (error) => Predicate.isTagged(error, "ControlUnavailableError"), }), Effect.catchTag("ControlUnavailableError", (error) => Effect.fail( diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts index 843d967e16..ef7ff3a57c 100644 --- a/packages/stack/src/managed/document.ts +++ b/packages/stack/src/managed/document.ts @@ -1,6 +1,6 @@ import { Data, Effect, Schema } from "effect"; import type { ManagedPortAssignment } from "./model.ts"; -import { PartialVersionManifestSchema, type PartialVersionManifest } from "../versions.ts"; +import { PartialVersionManifestSchema } from "../versions.ts"; export type ManagedStackDocumentLifecycle = | "stopped" @@ -9,6 +9,29 @@ export type ManagedStackDocumentLifecycle = | "deleting" | "failed"; +const managedStackLaunchFields = { + versions: PartialVersionManifestSchema, + excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), + lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), +} as const; + +export const managedStackLaunchUpdateSchema = Schema.Struct(managedStackLaunchFields); +export type ManagedStackLaunchUpdate = Schema.Schema.Type; + +const managedStackLaunchSchema = Schema.Union([ + Schema.Struct({ + mode: Schema.Literal("native"), + ...managedStackLaunchFields, + }), + Schema.Struct({ + mode: Schema.Literal("docker"), + containerRuntime: Schema.Literals(["docker", "podman"] as const), + ...managedStackLaunchFields, + }), +]); + +export type ManagedStackLaunch = Schema.Schema.Type; + export interface ManagedStackDocument { readonly format: "supabase-stack"; readonly formatVersion: 1; @@ -33,24 +56,18 @@ export interface ManagedStackDocument { readonly controlEndpoint: string; readonly protocolVersion: 1; }; - readonly launch?: { - readonly mode: "native" | "auto" | "docker"; - readonly versions: PartialVersionManifest; - readonly excludedServices?: ReadonlyArray; - readonly lastNotifiedUpdateFingerprint?: string; - }; + readonly launch: ManagedStackLaunch; readonly createdAt: string; readonly updatedAt: string; } -export const managedStackLaunchSchema = Schema.Struct({ - mode: Schema.Literals(["native", "auto", "docker"] as const), - versions: PartialVersionManifestSchema, - excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), - lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), +/** Launch request before the supervisor selects a concrete execution mode. */ +export const managedStackLaunchInputSchema = Schema.Struct({ + mode: Schema.optionalKey(Schema.Literals(["native", "docker"] as const)), + ...managedStackLaunchFields, }); -export type ManagedStackLaunch = Schema.Schema.Type; +export type ManagedStackLaunchInput = Schema.Schema.Type; const managedPortAssignmentSchema = Schema.Struct({ key: Schema.Literals([ @@ -94,7 +111,7 @@ const managedStackDocumentSchema = Schema.Struct({ protocolVersion: Schema.Literal(1), }), ), - launch: Schema.optionalKey(managedStackLaunchSchema), + launch: managedStackLaunchSchema, createdAt: Schema.String, updatedAt: Schema.String, }); @@ -115,34 +132,29 @@ export class InvalidManagedStackDocumentError extends Data.TaggedError( } } -const decodeDocument = Schema.decodeUnknownSync(ManagedStackDocumentSchema); -const encodeDocument = Schema.encodeUnknownSync(managedStackDocumentSchema); - export const decodeManagedStackDocument = ( path: string, content: string, ): Effect.Effect => - Effect.try({ - try: () => { - const document = decodeDocument(content); - if (!hasCorePortAssignments(document)) { - throw new Error("Managed document is missing core port assignments"); - } - return document; - }, - catch: () => new InvalidManagedStackDocumentError({ path }), - }); + Schema.decodeUnknownEffect(ManagedStackDocumentSchema)(content).pipe( + Effect.mapError(() => new InvalidManagedStackDocumentError({ path })), + Effect.flatMap((document) => + hasCorePortAssignments(document) + ? Effect.succeed(document) + : Effect.fail(new InvalidManagedStackDocumentError({ path })), + ), + ); export const encodeManagedStackDocument = ( path: string, document: ManagedStackDocument, ): Effect.Effect => - Effect.try({ - try: () => { - if (!hasCorePortAssignments(document)) { - throw new Error("Managed document is missing core port assignments"); - } - return JSON.stringify(encodeDocument(document), null, 2) + "\n"; - }, - catch: () => new InvalidManagedStackDocumentError({ path }), + Effect.gen(function* () { + if (!hasCorePortAssignments(document)) { + return yield* Effect.fail(new InvalidManagedStackDocumentError({ path })); + } + const encoded = yield* Schema.encodeEffect(managedStackDocumentSchema)(document).pipe( + Effect.mapError(() => new InvalidManagedStackDocumentError({ path })), + ); + return JSON.stringify(encoded, null, 2) + "\n"; }); diff --git a/packages/stack/src/managed/error-code.ts b/packages/stack/src/managed/error-code.ts deleted file mode 100644 index a77ceb2b16..0000000000 --- a/packages/stack/src/managed/error-code.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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 index f3f6c79e90..c7193c5186 100644 --- a/packages/stack/src/managed/failure.ts +++ b/packages/stack/src/managed/failure.ts @@ -11,48 +11,6 @@ export const causeMessage = (cause: unknown): string => { } }; -/** - * 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 rethrowing 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, with {@link asRaised} and {@link failsOnlyWith}. - * - * 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; - }; - /** * Narrows an effect's error channel to the one failure class a protocol reports, * turning everything else into a defect. @@ -63,10 +21,8 @@ export const failsWith = * inventing a protocol failure for them would hide what actually went wrong. * * The sorting happens after the effect fails rather than inside a `tryPromise` - * `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. Such a call therefore pairs a - * handler that classifies nothing — see {@link asRaised} — with this recovery. + * `catch` handler: `Effect.try` turns a throwing handler into a defect. Effects + * that need this recovery classify their own failures before reaching it. */ export const failsOnlyWith = (failure: abstract new (...args: never[]) => E) => @@ -74,6 +30,3 @@ export const failsOnlyWith = Effect.catch(effect, (error) => error instanceof failure ? Effect.fail(error) : Effect.die(error), ); - -/** A `catch` handler that classifies nothing, so it can never throw. */ -export const asRaised = (error: unknown): unknown => error; diff --git a/packages/stack/src/managed/git-identity.ts b/packages/stack/src/managed/git-identity.ts index 512d0886f3..1400f86ef4 100644 --- a/packages/stack/src/managed/git-identity.ts +++ b/packages/stack/src/managed/git-identity.ts @@ -1,34 +1,32 @@ -import { assertManagedUuid } from "./ids.ts"; +import { Effect, Schema } from "effect"; +import { validateManagedUuid } from "./ids.ts"; import { GIT_CHECKOUT_IDENTITY_VERSION, InvalidManagedIdentityError, type GitCheckoutIdentity, } from "./model.ts"; +const gitCheckoutIdentitySchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(GIT_CHECKOUT_IDENTITY_VERSION), + checkoutId: Schema.String, + }), +); + /** Internal versioned decoder shared by every Git checkout marker read path. */ -export const decodeGitCheckoutIdentity = (content: string): GitCheckoutIdentity => { - let value: unknown; - try { - value = JSON.parse(content); - } catch (cause: unknown) { - throw new InvalidManagedIdentityError({ - message: `The git checkout identity is not JSON: ${cause}`, - }); - } - if (typeof value !== "object" || value === null) { - throw new InvalidManagedIdentityError({ - message: "The git checkout identity must be an object", - }); - } - const version = Reflect.get(value, "version"); - if (version !== GIT_CHECKOUT_IDENTITY_VERSION) { - throw new InvalidManagedIdentityError({ - message: `Unsupported git checkout identity version ${String(version)}`, - }); - } - const checkoutId = Reflect.get(value, "checkoutId"); - if (typeof checkoutId !== "string") { - throw new InvalidManagedIdentityError({ message: "checkoutId must be an opaque UUID" }); - } - return { version, checkoutId: assertManagedUuid(checkoutId, "checkoutId") }; -}; +export const decodeGitCheckoutIdentity = ( + content: string, +): Effect.Effect => + Schema.decodeUnknownEffect(gitCheckoutIdentitySchema)(content).pipe( + Effect.mapError( + (error) => + new InvalidManagedIdentityError({ + message: `The git checkout identity is invalid: ${String(error)}`, + }), + ), + Effect.flatMap(({ version, checkoutId }) => + validateManagedUuid(checkoutId, "checkoutId").pipe( + Effect.map((validated) => ({ version, checkoutId: validated })), + ), + ), + ); diff --git a/packages/stack/src/managed/git.integration.test.ts b/packages/stack/src/managed/git.integration.test.ts index f22cea0a28..0fbd6728e4 100644 --- a/packages/stack/src/managed/git.integration.test.ts +++ b/packages/stack/src/managed/git.integration.test.ts @@ -1,6 +1,6 @@ import { BunFileSystem } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer } from "effect"; +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach } from "vitest"; @@ -17,7 +17,8 @@ import { inspectWorkspace, type GitCheckoutInspection, } from "./git.ts"; -import { UnsupportedGitWorkspaceError } from "./model.ts"; +import { ensureOrdinaryWorkspaceIdentity } from "./identity.ts"; +import { InvalidManagedIdentityError, UnsupportedGitWorkspaceError } from "./model.ts"; const { makeRoot, removeAll } = temporaryRoots("managed-git-test-"); const gitLayer = Layer.mergeAll(BunFileSystem.layer, gitConfigStoreLayer); @@ -132,6 +133,110 @@ describe("managed Git workspace identity", () => { expect(new Set(identities.map((identity) => identity.checkoutId)).size).toBe(3); expect(first.checkoutKind).toBe("linked-worktree"); expect(second.checkoutKind).toBe("linked-worktree"); + const thirdPath = join(root, "third"); + git(repository, "worktree", "add", "-q", thirdPath, "-b", "feature/third"); + const third = yield* inspectCheckout(thirdPath); + const raced = yield* Effect.all( + [ensureGitCheckoutIdentity(third), ensureGitCheckoutIdentity(third)], + { concurrency: "unbounded" }, + ); + expect(new Set(raced.map((identity) => identity.checkoutId)).size).toBe(1); + expect(raced.filter((identity) => identity.checkoutIdentityCreated)).toHaveLength(1); }).pipe(Effect.provide(gitLayer)), ); + + it.live("cleans an ordinary identity temp after interruption races exclusive open", () => + Effect.gen(function* () { + const root = makeRoot(); + const workspace = makeDirectory(root, "workspace"); + const markerPath = join(workspace, ".supabase", "identity.json"); + let tempOpenBlocked = false; + const layer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (fs) => ({ + ...fs, + open: (path, options) => { + if ( + !tempOpenBlocked && + path.startsWith(`${markerPath}.tmp.`) && + options?.flag === "wx" + ) { + tempOpenBlocked = true; + return fs + .open(path, options) + .pipe(Effect.flatMap((file) => Effect.interrupt.pipe(Effect.as(file)))); + } + return fs.open(path, options); + }, + })), + ).pipe(Layer.provide(BunFileSystem.layer)); + + const interrupted = yield* ensureOrdinaryWorkspaceIdentity(workspace).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(interrupted)).toBe(true); + + const fs = yield* Effect.gen(function* () { + return yield* FileSystem.FileSystem; + }).pipe(Effect.provide(layer)); + expect(yield* fs.exists(markerPath)).toBe(false); + expect(yield* fs.readDirectory(join(workspace, ".supabase"))).toEqual([]); + + const retry = yield* ensureOrdinaryWorkspaceIdentity(workspace).pipe(Effect.provide(layer)); + expect(retry.created).toBe(true); + }), + ); + + it.live("fails with a typed error when hard-link publication is unsupported", () => + Effect.gen(function* () { + const root = makeRoot(); + const repository = makeRepository(root); + const checkout = yield* inspectCheckout(repository).pipe(Effect.provide(gitLayer)); + const markerPath = join(checkout.gitDirectory, "supabase-checkout.json"); + const layer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (fs) => ({ + ...fs, + link: () => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "link", + cause: Object.assign(new Error("hard links unavailable"), { code: "EPERM" }), + }), + ), + })), + ); + const providedLayer = Layer.mergeAll( + layer.pipe(Layer.provide(BunFileSystem.layer)), + gitConfigStoreLayer, + ); + + const failure = yield* Effect.flip( + ensureGitCheckoutIdentity(checkout).pipe(Effect.provide(providedLayer)), + ); + expect(failure).toBeInstanceOf(UnsupportedGitWorkspaceError); + const fs = yield* Effect.gen(function* () { + return yield* FileSystem.FileSystem; + }).pipe(Effect.provide(providedLayer)); + expect(yield* fs.exists(markerPath)).toBe(false); + expect( + (yield* fs.readDirectory(checkout.gitDirectory)).filter((entry) => + entry.startsWith("supabase-checkout.json.tmp."), + ), + ).toEqual([]); + + const workspace = makeDirectory(root, "ordinary"); + const ordinaryFailure = yield* Effect.flip( + ensureOrdinaryWorkspaceIdentity(workspace).pipe(Effect.provide(providedLayer)), + ); + expect(ordinaryFailure).toBeInstanceOf(InvalidManagedIdentityError); + expect(ordinaryFailure.message).toContain("hard links"); + const ordinaryMetadata = join(workspace, ".supabase"); + expect(yield* fs.exists(join(ordinaryMetadata, "identity.json"))).toBe(false); + expect(yield* fs.readDirectory(ordinaryMetadata)).toEqual([]); + }), + ); }); diff --git a/packages/stack/src/managed/git.ts b/packages/stack/src/managed/git.ts index 5f08c93547..47df4409dd 100644 --- a/packages/stack/src/managed/git.ts +++ b/packages/stack/src/managed/git.ts @@ -1,13 +1,19 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; -import { Context, Duration, Effect, FileSystem, Layer, Schedule, type PlatformError } from "effect"; +import { + Context, + Duration, + Effect, + FileSystem, + Layer, + Predicate, + Schedule, + type PlatformError, +} from "effect"; import { claimFileAtomically } from "./atomic-claim.ts"; -import { errorCode } from "./error-code.ts"; -import { asRaised, failsOnlyWith, failsWith } from "./failure.ts"; -import { assertManagedUuid, createManagedUuid } from "./ids.ts"; +import { failsOnlyWith } from "./failure.ts"; +import { createManagedUuidEffect, validateManagedUuid } from "./ids.ts"; import { ensureGitCheckoutLocation, readGitCheckoutLocation } from "./identity.ts"; import { decodeGitCheckoutIdentity } from "./git-identity.ts"; import { @@ -100,7 +106,8 @@ const unsupported = ( Effect.fail(new UnsupportedGitWorkspaceError({ path, reason, workspaceCause })); const platformErrorPath = (error: PlatformError.PlatformError): string | undefined => - error.reason._tag === "BadArgument" || typeof error.reason.pathOrDescriptor !== "string" + Predicate.isTagged(error.reason, "BadArgument") || + typeof error.reason.pathOrDescriptor !== "string" ? undefined : error.reason.pathOrDescriptor; @@ -111,7 +118,7 @@ const inaccessiblePlatformError = ( const detail = error.reason.description === undefined ? error.message : error.reason.description; return unsupported( platformErrorPath(error) ?? fallbackPath, - `Git metadata is inaccessible (${error.reason._tag}): ${detail}`, + `Git metadata is inaccessible: ${detail}`, "metadata-inaccessible", ); }; @@ -126,7 +133,7 @@ const readOptionalFile = ( path: string, ): Effect.Effect => Effect.catch(fs.readFileString(path), (error) => - error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(undefined) : Effect.fail(error), ); const realPathOrMalformed = ( @@ -137,7 +144,7 @@ const realPathOrMalformed = ( Effect.catch( fs.realPath(path), (error): Effect.Effect => - error.reason._tag === "NotFound" + Predicate.isTagged(error.reason, "NotFound") ? unsupported(path, reason, "malformed-metadata") : Effect.fail(error), ); @@ -497,7 +504,7 @@ export const inspectWorkspace = ( return inspection; }).pipe( Effect.catchTag("PlatformError", (error) => - error.reason._tag === "BadArgument" + Predicate.isTagged(error.reason, "BadArgument") ? Effect.die(error) : inaccessiblePlatformError(workspacePath, error), ), @@ -509,27 +516,28 @@ export interface GitConfigStoreShape { readonly getAll: ( file: string, key: string, - ) => Effect.Effect, UnsupportedGitWorkspaceError>; + ) => Effect.Effect, UnsupportedGitWorkspaceError, FileSystem.FileSystem>; /** Read-only regexp query returning matching keys and values in file order. */ readonly getRegexp: ( file: string, regexp: string, ) => Effect.Effect< ReadonlyArray<{ readonly key: string; readonly value: string }>, - UnsupportedGitWorkspaceError + UnsupportedGitWorkspaceError, + FileSystem.FileSystem >; /** Appends a value to `key`, replacing nothing. */ readonly add: ( file: string, key: string, value: string, - ) => Effect.Effect; + ) => Effect.Effect; /** Collapses `key` to exactly `value`. */ readonly replace: ( file: string, key: string, value: string, - ) => Effect.Effect; + ) => Effect.Effect; } /** @@ -551,51 +559,82 @@ const MISSING_KEY_EXIT_CODE = 1; type GitConfigResult = | { readonly kind: "answered"; readonly stdout: string } | { readonly kind: "unset" } - | { readonly kind: "retryable"; readonly detail: string } - | { readonly kind: "failed"; readonly detail: string; readonly status?: number }; + | { + readonly kind: "failed"; + readonly detail: string; + readonly status?: number; + readonly lockFilePresent: boolean; + }; const runGitConfig = ( args: ReadonlyArray, tolerateUnset: boolean, file: string, -): Promise => - new Promise((settle) => { - execFile("git", ["config", ...args], { encoding: "utf8" }, (error, stdout, stderr) => { - if (error === null) { - settle({ kind: "answered", stdout }); - return; - } - // A non-zero exit reports the status as a number; a spawn failure reports an - // `errno` string instead, and is never something git decided. - const exitCode = error.code; - if (tolerateUnset && exitCode === MISSING_KEY_EXIT_CODE && stderr.trim().length === 0) { - settle({ kind: "unset" }); - return; - } - const detail = - stderr.trim().length > 0 - ? stderr.trim() - : typeof exitCode === "number" - ? `git config exited with status ${exitCode}` - : `git config could not be spawned (${String(exitCode)})`; - // A lock can disappear between git's refusal and this callback, so the - // concrete lock-file check is necessarily racy. Git's write statuses 4 - // and 255 are also ambiguous (they can mean a transient lock or a host - // failure), but bounded retry preserves safe concurrent claims; any - // terminal result is still reported as generic metadata-inaccessible. - if ( - (typeof exitCode === "number" && existsSync(`${file}.lock`)) || - (typeof exitCode === "number" && (exitCode === 4 || exitCode === 255)) - ) { - settle({ kind: "retryable", detail }); - } else { +): Effect.Effect => + Effect.gen(function* () { + const result = yield* Effect.callback((resume) => { + let settled = false; + let child: ReturnType | undefined; + const settle = (value: GitConfigResult) => { + if (settled) return; + settled = true; + resume(Effect.succeed(value)); + }; + const onComplete = ( + error: (Error & { readonly code?: number | string }) | null, + stdout: string, + stderr: string, + ) => { + if (error === null) { + settle({ kind: "answered", stdout }); + return; + } + const exitCode = error.code; + if (tolerateUnset && exitCode === MISSING_KEY_EXIT_CODE && stderr.trim().length === 0) { + settle({ kind: "unset" }); + return; + } + const detail = + stderr.trim().length > 0 + ? stderr.trim() + : typeof exitCode === "number" + ? `git config exited with status ${exitCode}` + : `git config could not be spawned (${String(exitCode)})`; settle({ kind: "failed", detail, status: typeof exitCode === "number" ? exitCode : undefined, + lockFilePresent: false, + }); + }; + try { + child = execFile("git", ["config", ...args], { encoding: "utf8" }, onComplete); + } catch (cause) { + settle({ + kind: "failed", + detail: `git config could not be spawned (${String(cause)})`, + lockFilePresent: false, }); } + return Effect.sync(() => { + if (!settled) { + settled = true; + try { + child?.kill("SIGTERM"); + } catch { + // The process may have exited between interruption and cleanup. + } + } + }); }); + if (result.kind !== "failed" || result.status === undefined) return result; + const fs = yield* FileSystem.FileSystem; + return { + ...result, + lockFilePresent: yield* fs + .exists(`${file}.lock`) + .pipe(Effect.catchTag("PlatformError", () => Effect.succeed(false))), + }; }); const gitLockRetrySchedule = () => @@ -612,45 +651,49 @@ const gitConfig = ( args: ReadonlyArray, tolerateUnset: boolean, file: string, -): Effect.Effect => - Effect.flatMap( - Effect.catch( - Effect.retry( - Effect.flatMap( - Effect.promise(() => runGitConfig(args, tolerateUnset, file)), - (result) => (result.kind === "retryable" ? Effect.fail(result) : Effect.succeed(result)), +): Effect.Effect => + Effect.gen(function* () { + const attempt = Effect.flatMap(runGitConfig(args, tolerateUnset, file), (value) => + value.kind === "failed" && + value.status !== undefined && + (value.lockFilePresent || value.status === 4 || value.status === 255) + ? Effect.fail(value) + : Effect.succeed(value), + ); + const result = yield* Effect.retry(attempt, { + while: () => true, + schedule: gitLockRetrySchedule(), + }).pipe( + Effect.catch((error: GitConfigResult) => + unsupported( + file, + `Git config is inaccessible (${error.kind === "failed" ? error.detail : "retry exhausted"})`, + "metadata-inaccessible", ), - { - while: (error) => error.kind === "retryable", - schedule: gitLockRetrySchedule(), - }, ), - (error) => - unsupported(file, `Git config is inaccessible (${error.detail})`, "metadata-inaccessible"), - ), - (result) => { - if (result.kind === "answered") return Effect.succeed(result.stdout); - if (result.kind === "unset") return Effect.succeed(undefined); - if (result.kind === "failed" && result.status === 128) { - return unsupported( - file, - `Git config is malformed (${result.detail})`, - "malformed-metadata", - ); - } - return unsupported( + ); + if (result.kind === "answered") return result.stdout; + if (result.kind === "unset") return undefined; + if (result.status === 128) { + return yield* unsupported( file, - `Git config is inaccessible (${result.detail})`, - "metadata-inaccessible", + `Git config is malformed (${result.detail})`, + "malformed-metadata", ); - }, - ); + } + return yield* unsupported( + file, + `Git config is inaccessible (${result.detail})`, + "metadata-inaccessible", + ); + }); /** A write refuses rather than tolerating an exit status of its own. */ const gitConfigWrite = ( args: ReadonlyArray, file: string, -): Effect.Effect => Effect.asVoid(gitConfig(args, false, file)); +): Effect.Effect => + Effect.asVoid(gitConfig(args, false, file)); export const gitConfigStoreLayer: Layer.Layer = Layer.succeed(GitConfigStore, { getAll: (file, key) => @@ -678,20 +721,12 @@ export const gitConfigStoreLayer: Layer.Layer = Layer.succeed(Gi const requireUuid = ( value: string, label: string, -): Effect.Effect => - Effect.try({ - try: () => assertManagedUuid(value, label), - catch: failsWith(InvalidManagedIdentityError), - }); +): Effect.Effect => validateManagedUuid(value, label); const mintUuid = ( idFactory: () => string, label: string, -): Effect.Effect => - Effect.try({ - try: () => createManagedUuid(idFactory, label), - catch: failsWith(InvalidManagedIdentityError), - }); +): Effect.Effect => createManagedUuidEffect(idFactory, label); /** * The value a config-stored identity has settled on. @@ -710,7 +745,7 @@ const readConfigId = ( ): Effect.Effect< string | undefined, InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore + GitConfigStore | FileSystem.FileSystem > => Effect.gen(function* () { const store = yield* GitConfigStore; @@ -745,7 +780,7 @@ const ensureConfigId = ( ): Effect.Effect< string, InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore + GitConfigStore | FileSystem.FileSystem > => Effect.gen(function* () { const store = yield* GitConfigStore; @@ -774,92 +809,92 @@ const ensureConfigId = ( return id; }); -const readCheckoutIdentity = async ( - gitDirectory: string, -): Promise => { - try { - return decodeGitCheckoutIdentity(await readFile(gitCheckoutIdentityPath(gitDirectory), "utf8")); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { - return undefined; - } - if ( - error instanceof InvalidManagedIdentityError || - error instanceof UnsupportedGitWorkspaceError - ) { - throw error; - } - throw new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${errorCode(error) ?? String(error)})`, - workspaceCause: "metadata-inaccessible", - }); - } -}; - -/** - * Claiming a checkout stays one `await` chain, for the reason - * `ensureOrdinaryWorkspaceIdentity` does: reading the marker, publishing the - * claim, and re-reading the marker a losing claimant must adopt are a single - * indivisible protocol, and an interruption between those steps would leave the - * caller with a checkout identity no git directory agreed to. - * - * The git directory always exists by the time this runs — inspection found it — - * so the marker needs no directory created for it. - */ interface CheckoutIdentityClaim { readonly checkoutId: string; /** Whether this call published the marker, rather than adopting a winner's. */ readonly created: boolean; } -const ensureCheckoutIdentity = async ( +const readCheckoutIdentity = ( + gitDirectory: string, +): Effect.Effect< + GitCheckoutIdentity | undefined, + InvalidManagedIdentityError | UnsupportedGitWorkspaceError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(gitCheckoutIdentityPath(gitDirectory)).pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(undefined) + : Effect.fail( + new UnsupportedGitWorkspaceError({ + path: gitCheckoutIdentityPath(gitDirectory), + reason: `Git checkout identity is inaccessible (${error.message})`, + workspaceCause: "metadata-inaccessible", + }), + ), + ), + ); + return content === undefined ? undefined : yield* decodeGitCheckoutIdentity(content); + }); + +const ensureCheckoutIdentity = ( gitDirectory: string, idFactory: () => string, -): Promise => { - const existing = await readCheckoutIdentity(gitDirectory); - if (existing !== undefined) { - return { checkoutId: existing.checkoutId, created: false }; - } +): Effect.Effect< + CheckoutIdentityClaim, + InvalidManagedIdentityError | UnsupportedGitWorkspaceError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const existing = yield* readCheckoutIdentity(gitDirectory); + if (existing !== undefined) return { checkoutId: existing.checkoutId, created: false }; + const markerPath = gitCheckoutIdentityPath(gitDirectory); - const identity: GitCheckoutIdentity = { - version: GIT_CHECKOUT_IDENTITY_VERSION, - checkoutId: createManagedUuid(idFactory, "checkoutId"), - }; - let outcome: Awaited>; - try { - outcome = await claimFileAtomically( - gitCheckoutIdentityPath(gitDirectory), + const identity: GitCheckoutIdentity = { + version: GIT_CHECKOUT_IDENTITY_VERSION, + checkoutId: yield* mintUuid(idFactory, "checkoutId"), + }; + const outcome = yield* claimFileAtomically( + markerPath, `${JSON.stringify(identity, null, 2)}\n`, - { - mode: 0o600, - }, + { mode: 0o600 }, + ).pipe( + Effect.catchTag("AtomicClaimUnsupportedError", (error) => + Effect.fail( + new UnsupportedGitWorkspaceError({ + path: markerPath, + reason: error.message, + workspaceCause: "metadata-inaccessible", + }), + ), + ), + Effect.catchTag("PlatformError", (error) => + Effect.fail( + new UnsupportedGitWorkspaceError({ + path: gitCheckoutIdentityPath(gitDirectory), + reason: `Git checkout identity is inaccessible (${error.message})`, + workspaceCause: "metadata-inaccessible", + }), + ), + ), ); - } catch (error: unknown) { - if ( - error instanceof InvalidManagedIdentityError || - error instanceof UnsupportedGitWorkspaceError - ) { - throw error; + if (outcome === "claimed") { + return { checkoutId: identity.checkoutId, created: true }; } - throw new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${errorCode(error) ?? String(error)})`, - workspaceCause: "metadata-inaccessible", - }); - } - if (outcome === "claimed") { - return { checkoutId: identity.checkoutId, created: true }; - } - const winner = await readCheckoutIdentity(gitDirectory); - if (winner === undefined) { - throw new InvalidManagedIdentityError({ - message: "Checkout identity publication raced without a winning marker", - }); - } - return { checkoutId: winner.checkoutId, created: false }; -}; + const winner = yield* readCheckoutIdentity(gitDirectory); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Checkout identity publication raced without a winning marker", + }), + ); + } + return { checkoutId: winner.checkoutId, created: false }; + }); export interface EnsureGitCheckoutIdentityResult { readonly workspaceId: string; @@ -902,7 +937,7 @@ export const ensureGitCheckoutIdentity = ( ): Effect.Effect< EnsureGitCheckoutIdentityResult, InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore + GitConfigStore | FileSystem.FileSystem > => failsWithIdentity( Effect.gen(function* () { @@ -912,10 +947,7 @@ export const ensureGitCheckoutIdentity = ( "workspaceId", idFactory, ); - const checkoutClaim = yield* Effect.tryPromise({ - try: () => ensureCheckoutIdentity(inspection.gitDirectory, idFactory), - catch: asRaised, - }); + const checkoutClaim = yield* ensureCheckoutIdentity(inspection.gitDirectory, idFactory); yield* ensureGitCheckoutLocation(inspection.gitDirectory, inspection.workspaceRoot); return { workspaceId, @@ -947,7 +979,7 @@ export const readGitCheckoutIdentityWithFileSystem = ( .readFileString(gitCheckoutIdentityPath(inspection.gitDirectory)) .pipe( Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(undefined) : Effect.fail( new UnsupportedGitWorkspaceError({ @@ -959,12 +991,7 @@ export const readGitCheckoutIdentityWithFileSystem = ( ), ); const identity = - content === undefined - ? undefined - : yield* Effect.try({ - try: () => decodeGitCheckoutIdentity(content), - catch: failsWith(InvalidManagedIdentityError), - }); + content === undefined ? undefined : yield* decodeGitCheckoutIdentity(content); const workspacePath = yield* readGitCheckoutLocation(inspection.gitDirectory); return { workspaceId, @@ -997,7 +1024,7 @@ export const ensureBranchContextId = ( ): Effect.Effect< string, InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore + GitConfigStore | FileSystem.FileSystem > => failsWithIdentity( Effect.flatMap(requireBranch(branch), (name) => @@ -1017,7 +1044,7 @@ export const readBranchContextId = ( ): Effect.Effect< string | undefined, InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore + GitConfigStore | FileSystem.FileSystem > => failsWithIdentity( Effect.flatMap(requireBranch(branch), (name) => diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index e69a923e3a..1a5b62cb60 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -1,16 +1,14 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { Effect, FileSystem, type PlatformError } from "effect"; -import { claimFileAtomically } from "./atomic-claim.ts"; +import { Effect, FileSystem, PlatformError, Predicate, Schema } from "effect"; +import { claimFileAtomically, type FileClaimOutcome } from "./atomic-claim.ts"; import { InvalidManagedIdentityError, ORDINARY_WORKSPACE_IDENTITY_VERSION, type OrdinaryWorkspaceIdentity, } from "./model.ts"; -import { assertManagedUuid, createManagedUuid } from "./ids.ts"; -import { asRaised, failsOnlyWith, failsWith } from "./failure.ts"; -import { errorCode } from "./error-code.ts"; +import { createManagedUuidEffect, validateManagedUuid } from "./ids.ts"; +import { failsOnlyWith } from "./failure.ts"; import { gitCheckoutLocationPath, gitDetachedContextIdentityPath, @@ -18,53 +16,64 @@ import { } from "./paths.ts"; import type { ControlOwnership } from "./control.ts"; -/** - * The marker's own failures are the only ones this module reports. Every - * protocol step here is a promise, so each one pairs a `catch` handler that - * classifies nothing with a recovery that sorts the failure afterwards. - */ const failsWithIdentity = failsOnlyWith(InvalidManagedIdentityError); -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 ordinaryWorkspaceIdentitySchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(ORDINARY_WORKSPACE_IDENTITY_VERSION), + workspaceId: Schema.String, + checkoutId: Schema.String, + contextId: Schema.String, + }), +); -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, - workspaceId: identityField(value, "workspaceId"), - checkoutId: identityField(value, "checkoutId"), - contextId: identityField(value, "contextId"), - }; -}; +const decodeIdentity = ( + content: string, +): Effect.Effect => + Schema.decodeUnknownEffect(ordinaryWorkspaceIdentitySchema)(content).pipe( + Effect.mapError( + (error) => + new InvalidManagedIdentityError({ + message: `The ordinary workspace identity is invalid: ${String(error)}`, + }), + ), + Effect.flatMap(({ version, workspaceId, checkoutId, contextId }) => + Effect.all({ + workspaceId: validateManagedUuid(workspaceId, "workspaceId"), + checkoutId: validateManagedUuid(checkoutId, "checkoutId"), + contextId: validateManagedUuid(contextId, "contextId"), + }).pipe(Effect.map((identity) => ({ version, ...identity }))), + ), + ); + +const inaccessibleIdentity = ( + label: string, + error: PlatformError.PlatformError, +): InvalidManagedIdentityError => + new InvalidManagedIdentityError({ message: `${label} is inaccessible (${error.message})` }); + +const claimIdentityFile = ( + path: string, + content: string, + label: string, + mode?: number, +): Effect.Effect => + claimFileAtomically(path, content, { mode }).pipe( + Effect.catchTag("AtomicClaimUnsupportedError", (error) => + Effect.fail( + new InvalidManagedIdentityError({ + message: `${label} could not be published at ${path}: ${error.message}. The filesystem must support hard links for managed identity publication.`, + }), + ), + ), + Effect.catchTag("PlatformError", (error) => + Effect.fail( + new InvalidManagedIdentityError({ + message: `${label} could not be published at ${path}: ${error.message}`, + }), + ), + ), + ); /** Effect FileSystem variant used by managed discovery. */ export const canonicalizeManagedWorkspacePathWithFileSystem = ( @@ -91,22 +100,7 @@ export const canonicalizeManagedWorkspacePathWithFileSystem = ( ), ); -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; - } -}; - -/** Read-only marker probe through Effect FileSystem; absence remains undefined. */ -export const readOrdinaryWorkspaceIdentityWithFileSystem = ( +const readIdentity = ( workspacePath: string, ): Effect.Effect< OrdinaryWorkspaceIdentity | undefined, @@ -116,163 +110,160 @@ export const readOrdinaryWorkspaceIdentityWithFileSystem = ( failsWithIdentity( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - try { - return decodeIdentity( - yield* fs.readFileString(ordinaryWorkspaceIdentityPath(workspacePath)), - ); - } catch (error) { - if (error instanceof InvalidManagedIdentityError) return yield* Effect.fail(error); - throw error; - } - }).pipe( - Effect.catchTag("PlatformError", (error: PlatformError.PlatformError) => - error.reason._tag === "NotFound" - ? Effect.succeed(undefined) - : Effect.fail( - new InvalidManagedIdentityError({ - message: `Ordinary workspace identity is inaccessible (${error.message})`, - }), - ), - ), - ), + return yield* fs.readFileString(ordinaryWorkspaceIdentityPath(workspacePath)).pipe( + Effect.flatMap((content) => decodeIdentity(content)), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(undefined) + : Effect.fail(inaccessibleIdentity("Ordinary workspace identity", error)), + ), + ); + }), ); +/** Read-only marker probe through Effect FileSystem; absence remains undefined. */ +export const readOrdinaryWorkspaceIdentityWithFileSystem = readIdentity; + 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: reading the marker, publishing the claim, and re-reading the marker - * a losing claimant must adopt are a single indivisible protocol, and an - * interruption between those steps would leave the caller with an identity no - * workspace agreed to. - */ -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, - workspaceId: createManagedUuid(idFactory, "workspaceId"), - checkoutId: createManagedUuid(idFactory, "checkoutId"), - contextId: createManagedUuid(idFactory, "contextId"), - }; - - await mkdir(dirname(markerPath), { recursive: true }); - const outcome = await claimFileAtomically(markerPath, `${JSON.stringify(identity, null, 2)}\n`, { - mode: 0o600, - }); - if (outcome === "claimed") { - return { identity, created: true, markerPath }; - } - - 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 }; -}; - export const ensureOrdinaryWorkspaceIdentity = ( workspacePath: string, idFactory: () => string = randomUUID, -): Effect.Effect => +): Effect.Effect< + EnsureOrdinaryWorkspaceIdentityResult, + InvalidManagedIdentityError, + FileSystem.FileSystem +> => failsWithIdentity( - Effect.tryPromise({ - try: () => ensureIdentity(workspacePath, idFactory), - catch: asRaised, + Effect.gen(function* () { + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + const existing = yield* readIdentity(workspacePath); + if (existing !== undefined) return { identity: existing, created: false, markerPath }; + + const identity: OrdinaryWorkspaceIdentity = { + version: ORDINARY_WORKSPACE_IDENTITY_VERSION, + workspaceId: yield* createManagedUuidEffect(idFactory, "workspaceId"), + checkoutId: yield* createManagedUuidEffect(idFactory, "checkoutId"), + contextId: yield* createManagedUuidEffect(idFactory, "contextId"), + }; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(markerPath), { recursive: true }); + const outcome = yield* claimIdentityFile( + markerPath, + `${JSON.stringify(identity, null, 2)}\n`, + "Ordinary workspace identity", + 0o600, + ); + if (outcome === "claimed") return { identity, created: true, markerPath }; + + const winner = yield* readIdentity(workspacePath); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Identity publication raced without a winning marker", + }), + ); + } + return { identity: winner, created: false, markerPath }; }), ); const DETACHED_CONTEXT_VERSION = 1; -const decodeDetachedContextId = (content: string): string => { - let value: unknown; - try { - value = JSON.parse(content); - } catch (cause: unknown) { - throw new InvalidManagedIdentityError({ - message: `The detached context identity is not JSON: ${cause}`, - }); - } - if (typeof value !== "object" || value === null) { - throw new InvalidManagedIdentityError({ - message: "The detached context identity must be an object", - }); - } - if (Reflect.get(value, "version") !== DETACHED_CONTEXT_VERSION) { - throw new InvalidManagedIdentityError({ - message: "Unsupported detached context identity version", - }); - } - const contextId = Reflect.get(value, "contextId"); - if (typeof contextId !== "string") { - throw new InvalidManagedIdentityError({ message: "contextId must be an opaque UUID" }); - } - return assertManagedUuid(contextId, "contextId"); -}; +const detachedContextIdentitySchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(DETACHED_CONTEXT_VERSION), + contextId: Schema.String, + }), +); -const readDetachedContextId = async (gitDirectory: string): Promise => { - try { - return decodeDetachedContextId( - await readFile(gitDetachedContextIdentityPath(gitDirectory), "utf8"), - ); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") return undefined; - throw error; - } -}; +const decodeDetachedContextId = ( + content: string, +): Effect.Effect => + Schema.decodeUnknownEffect(detachedContextIdentitySchema)(content).pipe( + Effect.mapError( + (error) => + new InvalidManagedIdentityError({ + message: `The detached context identity is invalid: ${String(error)}`, + }), + ), + Effect.flatMap(({ contextId }) => validateManagedUuid(contextId, "contextId")), + ); -export const readDetachedContextIdentity = ( +const readDetachedContextId = ( gitDirectory: string, -): Effect.Effect => +): Effect.Effect => failsWithIdentity( - Effect.tryPromise({ try: () => readDetachedContextId(gitDirectory), catch: asRaised }), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(gitDetachedContextIdentityPath(gitDirectory)).pipe( + Effect.flatMap((content) => decodeDetachedContextId(content)), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(undefined) + : Effect.fail(inaccessibleIdentity("Detached context identity", error)), + ), + ); + }), ); +export const readDetachedContextIdentity = readDetachedContextId; + export const ensureDetachedContextIdentity = ( gitDirectory: string, idFactory: () => string = randomUUID, ): Effect.Effect< { readonly contextId: string; readonly created: boolean }, - InvalidManagedIdentityError + InvalidManagedIdentityError, + FileSystem.FileSystem > => failsWithIdentity( - Effect.tryPromise({ - try: async () => { - const existing = await readDetachedContextId(gitDirectory); - if (existing !== undefined) return { contextId: existing, created: false }; - const contextId = createManagedUuid(idFactory, "contextId"); - const markerPath = gitDetachedContextIdentityPath(gitDirectory); - const outcome = await claimFileAtomically( - markerPath, - `${JSON.stringify({ version: DETACHED_CONTEXT_VERSION, contextId }, null, 2)}\n`, - { mode: 0o600 }, - ); - if (outcome === "claimed") return { contextId, created: true }; - const winner = await readDetachedContextId(gitDirectory); - if (winner === undefined) { - throw new InvalidManagedIdentityError({ + Effect.gen(function* () { + const existing = yield* readDetachedContextId(gitDirectory); + if (existing !== undefined) return { contextId: existing, created: false }; + const contextId = yield* createManagedUuidEffect(idFactory, "contextId"); + const markerPath = gitDetachedContextIdentityPath(gitDirectory); + const outcome = yield* claimIdentityFile( + markerPath, + `${JSON.stringify({ version: DETACHED_CONTEXT_VERSION, contextId }, null, 2)}\n`, + "Detached context identity", + 0o600, + ); + if (outcome === "claimed") return { contextId, created: true }; + const winner = yield* readDetachedContextId(gitDirectory); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ message: "Detached context publication raced without a winning marker", - }); - } - return { contextId: winner, created: false }; - }, - catch: asRaised, + }), + ); + } + return { contextId: winner, created: false }; }), ); +const checkoutLocationSchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + workspacePath: Schema.String, + }), +); + +const decodeLocation = (content: string): Effect.Effect => + Schema.decodeUnknownEffect(checkoutLocationSchema)(content).pipe( + Effect.mapError( + (error) => + new InvalidManagedIdentityError({ + message: `The git checkout location is invalid: ${String(error)}`, + }), + ), + Effect.map(({ workspacePath }) => workspacePath), + ); + export const readGitCheckoutLocation = ( gitDirectory: string, ): Effect.Effect => @@ -280,74 +271,59 @@ export const readGitCheckoutLocation = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; return yield* fs.readFileString(gitCheckoutLocationPath(gitDirectory)).pipe( - Effect.flatMap((content) => - Effect.try({ - try: () => decodeLocation(content), - catch: failsWith(InvalidManagedIdentityError), - }), - ), + Effect.flatMap((content) => decodeLocation(content)), Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(undefined) - : Effect.fail( - new InvalidManagedIdentityError({ - message: `Git checkout location is inaccessible (${error.message})`, - }), - ), + : Effect.fail(inaccessibleIdentity("Git checkout location", error)), ), ); }), ); -const decodeLocation = (content: string): string => { - let value: unknown; - try { - value = JSON.parse(content); - } catch (cause: unknown) { - throw new InvalidManagedIdentityError({ - message: `The git checkout location is not JSON: ${cause}`, - }); - } - if ( - typeof value !== "object" || - value === null || - typeof Reflect.get(value, "workspacePath") !== "string" - ) { - throw new InvalidManagedIdentityError({ message: "workspacePath must be a string" }); - } - return Reflect.get(value, "workspacePath"); -}; +const removeTemporary = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + fs.remove(path, { force: true }).pipe(Effect.ignore); + +const writeTemporary = ( + fs: FileSystem.FileSystem, + path: string, + content: string, +): Effect.Effect => + Effect.scoped( + fs + .open(path, { flag: "wx", mode: 0o600 }) + .pipe(Effect.flatMap((file) => file.writeAll(new TextEncoder().encode(content)))), + ); export const ensureGitCheckoutLocation = ( gitDirectory: string, workspacePath: string, ): Effect.Effect< { readonly workspacePath: string; readonly created: boolean }, - InvalidManagedIdentityError + InvalidManagedIdentityError, + FileSystem.FileSystem > => failsWithIdentity( - Effect.tryPromise({ - try: async () => { - const existing = await (async () => { - try { - return decodeLocation(await readFile(gitCheckoutLocationPath(gitDirectory), "utf8")); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") return undefined; - throw error; - } - })(); - if (existing !== undefined) return { workspacePath: existing, created: false }; - const markerPath = gitCheckoutLocationPath(gitDirectory); - const outcome = await claimFileAtomically( - markerPath, - `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, - { mode: 0o600 }, + Effect.gen(function* () { + const existing = yield* readGitCheckoutLocation(gitDirectory); + if (existing !== undefined) return { workspacePath: existing, created: false }; + const markerPath = gitCheckoutLocationPath(gitDirectory); + const outcome = yield* claimIdentityFile( + markerPath, + `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, + "Git checkout location", + 0o600, + ); + if (outcome === "claimed") return { workspacePath, created: true }; + const winner = yield* readGitCheckoutLocation(gitDirectory); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Checkout location publication raced without a winning marker", + }), ); - if (outcome === "claimed") return { workspacePath, created: true }; - const winner = decodeLocation(await readFile(markerPath, "utf8")); - return { workspacePath: winner, created: false }; - }, - catch: asRaised, + } + return { workspacePath: winner, created: false }; }), ); @@ -357,31 +333,34 @@ export const updateGitCheckoutLocationOwned = ( expectedPath: string, workspacePath: string, ownership: ControlOwnership, -): Effect.Effect<{ readonly workspacePath: string }, InvalidManagedIdentityError> => +): Effect.Effect< + { readonly workspacePath: string }, + InvalidManagedIdentityError, + FileSystem.FileSystem +> => failsWithIdentity( - Effect.tryPromise({ - try: async () => { - void ownership; - const markerPath = gitCheckoutLocationPath(gitDirectory); - const current = decodeLocation(await readFile(markerPath, "utf8")); - if (current !== expectedPath) { - throw new InvalidManagedIdentityError({ + Effect.gen(function* () { + void ownership; + const fs = yield* FileSystem.FileSystem; + const markerPath = gitCheckoutLocationPath(gitDirectory); + const current = yield* readGitCheckoutLocation(gitDirectory); + if (current === undefined || current !== expectedPath) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ message: "Git checkout location changed before repair publication", - }); - } - const temporaryPath = `${markerPath}.tmp.${randomUUID()}`; - await writeFile( - temporaryPath, - `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, - { mode: 0o600 }, + }), ); - try { - await rename(temporaryPath, markerPath); - } finally { - await unlink(temporaryPath).catch(() => undefined); - } - return { workspacePath }; - }, - catch: asRaised, + } + const temporaryPath = `${markerPath}.tmp.${randomUUID()}`; + const publication = writeTemporary( + fs, + temporaryPath, + `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, + ).pipe(Effect.andThen(fs.rename(temporaryPath, markerPath))); + yield* Effect.ensuring( + publication, + Effect.uninterruptible(removeTemporary(fs, temporaryPath)), + ); + return { workspacePath }; }), ); diff --git a/packages/stack/src/managed/ids.ts b/packages/stack/src/managed/ids.ts index 0f40ed2baa..a4056fa47e 100644 --- a/packages/stack/src/managed/ids.ts +++ b/packages/stack/src/managed/ids.ts @@ -1,13 +1,19 @@ +import { Effect } from "effect"; 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; -}; +/** Validate an identity without throwing from an Effect program. */ +export const validateManagedUuid = ( + value: unknown, + label: string, +): Effect.Effect => + typeof value === "string" && UUID_PATTERN.test(value) + ? Effect.succeed(value) + : Effect.fail(new InvalidManagedIdentityError({ message: `${label} must be an opaque UUID` })); -export const createManagedUuid = (idFactory: () => string, label: string): string => - assertManagedUuid(idFactory(), label); +export const createManagedUuidEffect = ( + idFactory: () => string, + label: string, +): Effect.Effect => + Effect.sync(idFactory).pipe(Effect.flatMap((value) => validateManagedUuid(value, label))); diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index fc77333617..ca78d99568 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -6,16 +6,16 @@ import { dockerForceRemove } from "../cleanup.ts"; import { dockerContainerName } from "../StackIdentity.ts"; import { SERVICE_NAMES } from "../ServiceCatalog.ts"; import { HttpTransportClient, HttpTransportClientError } from "../HttpTransportClient.ts"; -import type { ManagedStackDocument } from "./document.ts"; +import type { ManagedStackDocument, ManagedStackLaunchUpdate } from "./document.ts"; import { ManagedStackAttachedError, ManagedStackManager, ManagedWorkspaceRepairConflictError, workspaceRepairConflict, type ManagedStackManagerError, - type ManagedStackLaunchUpdate, + type ManagedStackLaunchUpdateRequest, } from "./manager.ts"; -import { ControlTransportError } from "./control.ts"; +import { ControlTransportError, isControlOwnership } from "./control.ts"; import { ManagedStackNotStoppedError, type ManagedPortIntentDocument, @@ -114,6 +114,8 @@ export const stopManagedStack = ( const manager = yield* ManagedStackManager; const document = yield* resolveManagedDocument(input); const stackId = document.id; + const containerRuntime = + document.launch.mode === "docker" ? document.launch.containerRuntime : null; const acquisition = yield* manager.acquireControl(stackId); const revalidatedStackId = yield* stackIdForInput(manager, input); if (revalidatedStackId !== stackId) { @@ -123,15 +125,18 @@ export const stopManagedStack = ( }), ); } - if (acquisition._tag === "Owned") { + if (isControlOwnership(acquisition)) { if ( document.lifecycle === "running" || document.lifecycle === "starting" || document.lifecycle === "failed" ) { - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (containerRuntime !== null) { + yield* dockerForceRemove( + containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } yield* manager.recordLifecycle(acquisition, { stackId, lifecycle: "stopped" }); } yield* acquisition.close; @@ -144,9 +149,12 @@ export const stopManagedStack = ( const cleanupOwned = (owned: import("./control.ts").ControlOwnership) => Effect.ensuring( Effect.gen(function* () { - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (containerRuntime !== null) { + yield* dockerForceRemove( + containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } yield* manager.recordLifecycle(owned, { stackId, lifecycle: "stopped" }); }), owned.close, @@ -208,7 +216,7 @@ export const stopManagedStack = ( if (ready === "dead") { const released = yield* manager.acquireControl(stackId).pipe( Effect.flatMap((candidate) => - candidate._tag === "Owned" + isControlOwnership(candidate) ? Effect.succeed(candidate) : Effect.fail(new ManagedStopPending()), ), @@ -238,7 +246,7 @@ export const stopManagedStack = ( ); const released = yield* manager.acquireControl(stackId).pipe( Effect.flatMap((candidate) => - candidate._tag === "Owned" + isControlOwnership(candidate) ? Effect.succeed(candidate) : Effect.fail(new ManagedStopPending()), ), @@ -260,7 +268,7 @@ export const deleteManagedStack = ( Effect.gen(function* () { const acquisition = yield* manager.acquireControl(stackId).pipe( Effect.flatMap((candidate) => - candidate._tag === "Owned" + isControlOwnership(candidate) ? Effect.succeed(candidate) : Effect.fail(new ManagedDeletePending()), ), @@ -285,7 +293,7 @@ export const deleteManagedStack = ( /** Persist launch selections in the managed document, owner-gated. */ export const updateManagedLaunch = ( - input: ManagedLifecycleInput & { readonly launch: NonNullable }, + input: ManagedLifecycleInput & { readonly launch: ManagedStackLaunchUpdate }, ): Effect.Effect< ManagedStackDocument, NoRunningStackError | ManagedStackManagerError | HttpTransportClientError, @@ -296,7 +304,7 @@ export const updateManagedLaunch = ( const document = yield* resolveManagedDocument(input); const manager = yield* ManagedStackManager; const acquisition = yield* manager.acquireControl(document.id); - if (acquisition._tag !== "Owned") { + if (!isControlOwnership(acquisition)) { if (document.lifecycle !== "running" || document.runtime?.controlEndpoint === undefined) { return yield* Effect.fail(new ManagedStackAttachedError({ stackId: document.id })); } @@ -313,7 +321,10 @@ export const updateManagedLaunch = ( if (next === undefined) return yield* Effect.fail(noRunningStack(input)); return next; } - const update: ManagedStackLaunchUpdate = { stackId: document.id, launch: input.launch }; + const update: ManagedStackLaunchUpdateRequest = { + stackId: document.id, + launch: input.launch, + }; return yield* Effect.ensuring(manager.updateLaunch(acquisition, update), acquisition.close); }), ); diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 7746b11c8a..a8903f15db 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -8,23 +8,20 @@ import { Layer, Path, PlatformError, + Predicate, Schedule, Semaphore, Scope, } from "effect"; import { isAbsolute, relative, resolve } from "node:path"; -import { PORT_CATALOG, type PortField, type PortSet } from "../PortCatalog.ts"; -import { - reservePortSet, - type PortAllocationError, - type PortLease, - type PortReservationRequest, -} from "../PortAllocator.ts"; +import { PORT_CATALOG, type PortSet } from "../PortCatalog.ts"; +import { reservePortSet, type PortLease, type PortReservationRequest } from "../PortAllocator.ts"; import { acquireControl, CONTROL_PORT_RANGE, controlEndpointCandidates, ControlTransport, + isControlOwnership, probeControl, type ControlAcquisition, type ControlOwnership, @@ -53,6 +50,7 @@ import { ManagedPortAllocationError, ManagedStackNotFoundError, ManagedStackNotStoppedError, + UnsafeManagedStackPathError, type ManagedPortAssignment, type ManagedPortDrift, type ManagedPortIntentDocument, @@ -65,7 +63,7 @@ import { } from "./port-plan.ts"; import { resolvePortIntents } from "./port-intent.ts"; import { makeStackStore, type ManagedStackListing } from "./store.ts"; -import type { ManagedStackDocument } from "./document.ts"; +import type { ManagedStackDocument, ManagedStackLaunchUpdate } from "./document.ts"; import { dockerForceRemove } from "../cleanup.ts"; import { SERVICE_NAMES } from "../ServiceCatalog.ts"; import { dockerContainerName } from "../StackIdentity.ts"; @@ -89,7 +87,7 @@ export interface StartStackRequest { readonly ownership: ControlOwnership; readonly lifecycle?: ManagedStackDocument["lifecycle"]; readonly runtime?: ManagedStackDocument["runtime"]; - readonly launch?: ManagedStackDocument["launch"]; + readonly launch: ManagedStackDocument["launch"]; } export interface AllocateManagedPortsRequest { @@ -100,7 +98,7 @@ export interface AllocateManagedPortsRequest { export interface ManagedPortAllocation { readonly assignments: ReadonlyArray; - readonly lease: ManagedPortLease; + readonly lease: PortLease; } interface ManagedStackStartResultBase { @@ -109,7 +107,7 @@ interface ManagedStackStartResultBase { export type ManagedStackStartResult = ManagedStackStartResultBase & { /** The lease remains live until the caller's Effect scope closes. */ - readonly lease: ManagedPortLease; + readonly lease: PortLease; }; export interface ManagedStackLifecycleUpdate { @@ -119,17 +117,12 @@ export interface ManagedStackLifecycleUpdate { readonly runtime?: ManagedStackDocument["runtime"] | null; } -export interface ManagedStackLaunchUpdate { +export interface ManagedStackLaunchUpdateRequest { readonly stackId: string; - readonly launch: NonNullable; + readonly launch: ManagedStackLaunchUpdate; } -export interface ManagedPortLease { - readonly ports: PortSet; - readonly reserve: (fields: ReadonlyArray) => Effect.Effect; - readonly release: (fields: ReadonlyArray) => Effect.Effect; - readonly releaseAll: Effect.Effect; -} +export type ManagedPortLease = PortLease; export type ManagedDeleteResult = | { readonly outcome: "removed"; readonly stackId: string } @@ -187,6 +180,7 @@ export type ManagedStackManagerError = | PlatformError.PlatformError | import("./document.ts").InvalidManagedStackDocumentError | import("./model.ts").InvalidManagedIdentityError + | UnsafeManagedStackPathError | InvalidManagedStackNameError | import("./model.ts").UnsupportedGitWorkspaceError | import("./control.ts").InvalidControlOwnershipIdError @@ -240,7 +234,7 @@ export interface ManagedStackManagerShape { /** Persist launch selections under the stack's control ownership. */ readonly updateLaunch: ( ownership: ControlOwnership, - update: ManagedStackLaunchUpdate, + update: ManagedStackLaunchUpdateRequest, ) => Effect.Effect; readonly repairWorkspace: ( request: RepairRequest, @@ -252,6 +246,9 @@ export interface ManagedStackManagerShape { } type ManagerRequirements = FileSystem.FileSystem | Path.Path | GitConfigStore | ControlTransport; +export type ManagedStackManagerConstructionError = + | InvalidManagedIdentityError + | UnsafeManagedStackPathError; export class ManagedStackManager extends Context.Service< ManagedStackManager, @@ -279,7 +276,7 @@ export const deriveRepairOwnershipId = (identity: EnvironmentIdentity): string = }; const isOwned = (acquisition: ControlAcquisition): acquisition is ControlOwnership => - acquisition._tag === "Owned"; + isControlOwnership(acquisition); const isHealthyDocument = ( listing: ManagedStackListing, @@ -318,11 +315,29 @@ const stackDrift = ( }); }; -const portRequests = (plan: ManagedPortPlan): ReadonlyArray => +const portRequests = ( + plan: ManagedPortPlan, + automaticExcluded: ReadonlySet = new Set(), +): ReadonlyArray => [...plan.durable] - .sort((left, right) => Number(right.intent === "exact") - Number(left.intent === "exact")) - .map(({ field, selection }) => ({ field, selection })) - .concat(plan.runtimeOnly); + .sort( + (left, right) => + Number(right.selection.kind === "exact") - Number(left.selection.kind === "exact"), + ) + .map(({ field, selection }) => ({ + field, + selection: + selection.kind === "automatic" ? { ...selection, excluded: automaticExcluded } : selection, + })) + .concat( + plan.runtimeOnly.map(({ field, selection }) => ({ + field, + selection: + selection.kind === "automatic" + ? { ...selection, excluded: automaticExcluded } + : selection, + })), + ); const managedAssignments = ( plan: ManagedPortPlan, @@ -346,6 +361,30 @@ const managedAssignments = ( ); }); +/** + * Acquires a managed port lease, owns it before using it, and releases it on + * every non-successful exit. The scope finalizer is registered immediately + * after acquisition so a successful lease remains owned until its caller's + * scope closes. + */ +export const withManagedPortLease = ( + acquire: Effect.Effect, + use: (lease: PortLease) => Effect.Effect, +): Effect.Effect<{ readonly value: A; readonly lease: PortLease }, E, R | Scope.Scope> => + Effect.gen(function* () { + const parentScope = yield* Effect.scope; + const leaseScope = yield* Scope.fork(parentScope); + const attempt = Effect.acquireRelease(acquire, (lease) => lease.releaseAll).pipe( + Scope.provide(leaseScope), + Effect.flatMap((lease) => use(lease).pipe(Effect.map((value) => ({ value, lease })))), + ); + return yield* attempt.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : Effect.uninterruptible(Scope.close(leaseScope, exit)), + ), + ); + }); + const requireOwnedForStack = ( ownership: ControlOwnership, stackId: string, @@ -376,7 +415,11 @@ interface ManagedStackManagerOptions { const makeManager = ( options: ManagedStackManagerOptions, -): Effect.Effect => +): Effect.Effect< + ManagedStackManagerShape, + ManagedStackManagerConstructionError, + ManagerRequirements +> => Effect.gen(function* () { const { stateRoot, preferCatalogDefaults = true } = options; const fileSystem = yield* FileSystem.FileSystem; @@ -415,7 +458,9 @@ const makeManager = ( .stat(persistedPath) .pipe( Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(undefined) + : Effect.fail(error), ), ); if (persistedInfo === undefined || persistedInfo.type !== "Directory") continue; @@ -457,7 +502,6 @@ const makeManager = ( Effect.gen(function* () { yield* requireOwnedForStack(ownership, request.stackId); const persisted = request.persisted ?? []; - const partialLeases: Array = []; const attempt = Effect.gen(function* () { const listings = yield* store.list(); const plan = planManagedPorts({ @@ -467,9 +511,6 @@ const makeManager = ( persisted, preferCatalogDefaults, }); - const requests = portRequests(plan); - const exactRequests = requests.filter((item) => item.selection.kind === "exact"); - const automaticRequests = requests.filter((item) => item.selection.kind === "automatic"); const invalidPersistedAutomatic = plan.durable.find( (entry) => entry.intent === "automatic" && @@ -533,6 +574,19 @@ const makeManager = ( for (const assignment of plan.inactiveAssignments) { strictReserved.add(assignment.port); } + const automaticExcluded = new Set(); + for (let port = CONTROL_PORT_RANGE.min; port <= CONTROL_PORT_RANGE.max; port += 1) { + automaticExcluded.add(port); + } + for (const port of strictReserved) automaticExcluded.add(port); + for (const [port] of owners) automaticExcluded.add(port); + for (const assignment of plan.inactiveAssignments) { + automaticExcluded.add(assignment.port); + } + for (const port of exactReserved) automaticExcluded.add(port); + + const requests = portRequests(plan, automaticExcluded); + const exactRequests = requests.filter((item) => item.selection.kind === "exact"); const requestedAssignments = exactRequests.flatMap((item) => { const entry = plan.durable.find((candidate) => candidate.field === item.field); if (entry?.selection.kind !== "exact") return []; @@ -544,6 +598,16 @@ const makeManager = ( } satisfies ManagedPortAssignment, ]; }); + for (const assignment of requestedAssignments) { + if (!exactReserved.has(assignment.port)) continue; + return yield* Effect.fail( + new ManagedExactPortOccupiedError({ + key: assignment.key, + port: assignment.port, + stackId: request.stackId, + }), + ); + } for (const assignment of requestedAssignments) { const owner = (owners.get(assignment.port) ?? []).find((candidate) => { const lifecycle = @@ -575,107 +639,38 @@ const makeManager = ( ); } } - const exactLease = - exactRequests.length === 0 - ? undefined - : yield* reservePortSet(exactRequests, { reserved: exactReserved }).pipe( - Effect.mapError((cause) => { - const entry = - cause.field === undefined - ? undefined - : plan.durable.find((candidate) => candidate.field === cause.field); - const key = - entry?.intent === "exact" ? PORT_CATALOG[entry.field].configKey : undefined; - return key !== undefined && cause.port !== undefined - ? new ManagedExactPortOccupiedError({ - key, - port: cause.port, - stackId: request.stackId, - }) - : new ManagedPortAllocationError({ - fields: exactRequests.map((item) => item.field), - cause, - }); - }), - ); - if (exactLease !== undefined) partialLeases.push(exactLease); - const automaticReserved = new Set(); - for (let port = CONTROL_PORT_RANGE.min; port <= CONTROL_PORT_RANGE.max; port += 1) { - automaticReserved.add(port); - } - for (const port of strictReserved) automaticReserved.add(port); - for (const [port] of owners) { - automaticReserved.add(port); - } - if (exactLease !== undefined) { - for (const port of Object.values(exactLease.ports)) { - if (port !== undefined) automaticReserved.add(port); - } - } - const automaticLease = - automaticRequests.length === 0 - ? undefined - : yield* reservePortSet(automaticRequests, { reserved: automaticReserved }).pipe( - Effect.mapError( - (cause) => - new ManagedPortAllocationError({ - fields: automaticRequests.map((item) => item.field), - cause, - }), - ), - ); - if (automaticLease !== undefined) partialLeases.push(automaticLease); - const ports: PortSet = { - ...exactLease?.ports, - ...automaticLease?.ports, - }; - const assignments = yield* managedAssignments(plan, ports); - const lease: ManagedPortLease = { - ports, - reserve: (fields) => - Effect.all( - [ - exactLease?.reserve( - fields.filter((field) => exactLease.ports[field] !== undefined), - ) ?? Effect.void, - automaticLease?.reserve( - fields.filter((field) => automaticLease.ports[field] !== undefined), - ) ?? Effect.void, - ], - { discard: true }, - ), - release: (fields) => - Effect.all( - [ - exactLease?.release(fields) ?? Effect.void, - automaticLease?.release(fields) ?? Effect.void, - ], - { discard: true }, - ), - releaseAll: Effect.all( - [exactLease?.releaseAll ?? Effect.void, automaticLease?.releaseAll ?? Effect.void], - { discard: true }, + const allocation = yield* withManagedPortLease( + reservePortSet(requests, { reserved: exactReserved }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError((cause) => { + const entry = + cause.field === undefined + ? undefined + : plan.durable.find((candidate) => candidate.field === cause.field); + const key = + entry?.intent === "exact" ? PORT_CATALOG[entry.field].configKey : undefined; + return key !== undefined && cause.port !== undefined + ? new ManagedExactPortOccupiedError({ + key, + port: cause.port, + stackId: request.stackId, + }) + : new ManagedPortAllocationError({ + fields: requests.map((item) => item.field), + cause, + }); + }), ), - }; - return { assignments, lease }; + (lease) => managedAssignments(plan, lease.ports), + ); + return { assignments: allocation.value, lease: allocation.lease }; }); - const guardedAttempt = Effect.exit(attempt).pipe( - Effect.flatMap((exit) => - Exit.isSuccess(exit) - ? Effect.succeed(exit.value) - : Effect.all( - partialLeases.map((lease) => lease.releaseAll), - { discard: true }, - ).pipe(Effect.andThen(Effect.failCause(exit.cause))), - ), - ); - const allocation = yield* guardedAttempt.pipe( + const allocation = yield* attempt.pipe( Effect.retry({ schedule: Schedule.spaced("5 millis").pipe(Schedule.upTo({ times: 2 })), - while: (error) => error._tag === "ManagedPortAllocationError", + while: (error) => Predicate.isTagged(error, "ManagedPortAllocationError"), }), ); - yield* Effect.addFinalizer(() => allocation.lease.releaseAll); return allocation; }); @@ -778,9 +773,7 @@ const makeManager = ( ports: allocation.assignments, lifecycle: request.lifecycle ?? "stopped", ...(request.runtime && { runtime: request.runtime }), - ...((request.launch ?? current?.launch) && { - launch: request.launch ?? current?.launch, - }), + launch: request.launch, createdAt: current?.createdAt ?? timestamp, updatedAt: timestamp, }; @@ -843,7 +836,7 @@ const makeManager = ( const updateLaunch = ( ownership: ControlOwnership, - update: ManagedStackLaunchUpdate, + update: ManagedStackLaunchUpdateRequest, ): Effect.Effect => lifecycleLock.withPermit( Effect.gen(function* () { @@ -852,9 +845,28 @@ const makeManager = ( if (current === undefined) { return yield* Effect.fail(new ManagedStackNotFoundError({ stackId: update.stackId })); } + const metadata = { + versions: update.launch.versions, + ...(update.launch.excludedServices === undefined + ? {} + : { excludedServices: update.launch.excludedServices }), + ...(update.launch.lastNotifiedUpdateFingerprint === undefined + ? {} + : { + lastNotifiedUpdateFingerprint: update.launch.lastNotifiedUpdateFingerprint, + }), + }; + const launch: ManagedStackDocument["launch"] = + current.launch.mode === "native" + ? { ...metadata, mode: "native" } + : { + ...metadata, + mode: "docker", + containerRuntime: current.launch.containerRuntime, + }; const next: ManagedStackDocument = { ...current, - launch: update.launch, + launch, updatedAt: now(), }; yield* store.write(next); @@ -945,11 +957,13 @@ const makeManager = ( updatedAt, }); } - yield* updateGitCheckoutLocationOwned( - inspection.gitDirectory, - revalidated.expectedPath, - revalidated.path, - repairAcquisition, + yield* provideDependencies( + updateGitCheckoutLocationOwned( + inspection.gitDirectory, + revalidated.expectedPath, + revalidated.path, + repairAcquisition, + ), ); return yield* provideDependencies(discoverEnvironment(revalidated.path)); }), @@ -968,7 +982,7 @@ const makeManager = ( store.remove(stackId).pipe(Effect.as({ outcome: "removed" as const, stackId })), ), Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(undefined) : store.remove(stackId).pipe(Effect.as({ outcome: "removed" as const, stackId })), ), @@ -976,9 +990,12 @@ const makeManager = ( if (current === undefined) return { outcome: "already-absent", stackId }; if ("outcome" in current) return current; yield* acquisition.setState("deleting", false); - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (current.launch.mode === "docker") { + yield* dockerForceRemove( + current.launch.containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } const { runtime: _runtime, ...withoutRuntime } = current; const deleting = { ...withoutRuntime, lifecycle: "deleting" as const, updatedAt: now() }; yield* store.write(deleting); @@ -1018,10 +1035,13 @@ const makeManager = ( /** Internal manager layer. Platform layers provide filesystem, Git, and control transport. */ export const managedStackManagerLayer = ( options: ManagedStackManagerOptions, -): Layer.Layer => +): Layer.Layer => Layer.effect(ManagedStackManager, makeManager(options)); export const makeManagedStackManager = ( stateRoot: string, -): Effect.Effect => - makeManager({ stateRoot }); +): Effect.Effect< + ManagedStackManagerShape, + ManagedStackManagerConstructionError, + ManagerRequirements +> => makeManager({ stateRoot }); diff --git a/packages/stack/src/managed/manager.unit.test.ts b/packages/stack/src/managed/manager.unit.test.ts new file mode 100644 index 0000000000..d99cf16f84 --- /dev/null +++ b/packages/stack/src/managed/manager.unit.test.ts @@ -0,0 +1,48 @@ +import { it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber } from "effect"; +import { describe, expect } from "vitest"; +import { withManagedPortLease } from "./manager.ts"; +import type { PortLease } from "../PortAllocator.ts"; + +describe("managed port lease ownership", () => { + it.effect("releases an acquired lease when use is interrupted", () => + Effect.gen(function* () { + const acquired = yield* Deferred.make(); + const hold = yield* Deferred.make(); + let releases = 0; + const lease: PortLease = { + ports: { apiPort: 54321 }, + reserve: () => Effect.void, + release: () => Effect.void, + releaseAll: Effect.sync(() => { + releases += 1; + }), + }; + + const fiber = yield* withManagedPortLease( + Deferred.succeed(acquired, undefined).pipe(Effect.andThen(Effect.succeed(lease))), + () => Deferred.await(hold), + ).pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(acquired); + yield* Fiber.interrupt(fiber); + expect(releases).toBe(1); + + let failedReleases = 0; + const failedLease: PortLease = { + ports: { apiPort: 54322 }, + reserve: () => Effect.void, + release: () => Effect.void, + releaseAll: Effect.sync(() => { + failedReleases += 1; + }), + }; + const failed = yield* withManagedPortLease(Effect.succeed(failedLease), () => + Effect.fail("use failed"), + ).pipe(Effect.scoped, Effect.exit); + + expect(Exit.isFailure(failed)).toBe(true); + expect(failedReleases).toBe(1); + }), + ); +}); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index 6a2e33c6fb..dc792558ac 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect"; +import { Data, Effect, Predicate } from "effect"; import type { ConfigPortKey, PortField } from "../PortCatalog.ts"; import { causeMessage } from "./failure.ts"; @@ -227,7 +227,6 @@ export const MANAGED_ERROR_TAG_BY_CODE = { const MANAGED_ERROR_TAGS: ReadonlySet = new Set(Object.values(MANAGED_ERROR_TAG_BY_CODE)); 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); + if (!(error instanceof Error)) return false; + return [...MANAGED_ERROR_TAGS].some((tag) => Predicate.isTagged(error, tag)); } diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 83d0bfdf51..9241ccce85 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -1,7 +1,12 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { assertManagedUuid } from "./ids.ts"; -import { UnsafeManagedStackPathError, type ManagedStackPaths } from "./model.ts"; +import { Effect } from "effect"; +import { validateManagedUuid } from "./ids.ts"; +import { + InvalidManagedIdentityError, + UnsafeManagedStackPathError, + type ManagedStackPaths, +} from "./model.ts"; export interface ManagedStateRootOptions { readonly stateRoot?: string; @@ -15,15 +20,18 @@ const nonEmpty = (value: string | undefined): string | undefined => { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; }; -const requireManagedStateRoot = (stateRoot: string): string => { +const requireManagedStateRootEffect = ( + stateRoot: string, +): Effect.Effect => { const trimmed = nonEmpty(stateRoot); - if (trimmed === undefined) { - throw new UnsafeManagedStackPathError({ - path: stateRoot, - reason: "Refusing a blank managed state root", - }); - } - return resolve(trimmed); + return trimmed === undefined + ? Effect.fail( + new UnsafeManagedStackPathError({ + path: stateRoot, + reason: "Refusing a blank managed state root", + }), + ) + : Effect.succeed(resolve(trimmed)); }; /** @@ -31,7 +39,7 @@ const requireManagedStateRoot = (stateRoot: string): string => { * 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. + * {@link assertManagedStackRootEffect} 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 @@ -41,36 +49,39 @@ const requireManagedStateRoot = (stateRoot: string): string => { * 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 => { +export const resolveManagedStateRootEffect = ( + options: ManagedStateRootOptions = {}, +): Effect.Effect => { if (options.stateRoot !== undefined) { - return requireManagedStateRoot(options.stateRoot); + return requireManagedStateRootEffect(options.stateRoot); } - const env = options.env ?? process.env; const configuredHome = nonEmpty(env["SUPABASE_HOME"]); if (configuredHome !== undefined) { - return join(resolve(configuredHome), "managed"); + return Effect.succeed(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"); + return Effect.succeed(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", + return Effect.succeed( + 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", + return Effect.succeed( + join( + stateHome === undefined ? join(userHome, ".local", "state") : resolve(stateHome), + "supabase", + "managed", + ), ); }; @@ -79,58 +90,70 @@ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): * * `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 + * would make {@link resolveManagedStateRootEffect} 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 requireExplicitManagedStateRootEffect = ( + stateRoot: string | undefined, +): Effect.Effect => + stateRoot === undefined + ? Effect.fail( + new UnsafeManagedStackPathError({ + path: String(stateRoot), + reason: "Refusing to start a managed stack service without an explicit state root", + }), + ) + : resolveManagedStateRootEffect({ stateRoot }); export const managedStacksRoot = (stateRoot: string): string => join(stateRoot, "stacks"); const SHA256_STACK_ID_PATTERN = /^[0-9a-f]{64}$/i; -const assertManagedStackId = (stackId: string): string => { - if (SHA256_STACK_ID_PATTERN.test(stackId)) { - return stackId; - } - return assertManagedUuid(stackId, "stackId"); -}; +const validateManagedStackId = ( + stackId: string, +): Effect.Effect => + SHA256_STACK_ID_PATTERN.test(stackId) + ? Effect.succeed(stackId) + : validateManagedUuid(stackId, "stackId"); -export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { - assertManagedStackId(stackId); - const root = join(managedStacksRoot(stateRoot), stackId); - return { - root, - data: join(root, "data"), - logs: join(root, "logs"), - runtime: join(root, "runtime"), - }; -}; +export const managedStackPathsEffect = ( + stateRoot: string, + stackId: string, +): Effect.Effect => + validateManagedStackId(stackId).pipe( + Effect.map((id) => { + const root = join(managedStacksRoot(stateRoot), id); + return { + root, + data: join(root, "data"), + logs: join(root, "logs"), + runtime: join(root, "runtime"), + }; + }), + ); -export const managedStackDocumentPath = (stateRoot: string, stackId: string): string => - join(managedStackPaths(stateRoot, stackId).root, "stack.json"); +export const managedStackDocumentPathEffect = ( + stateRoot: string, + stackId: string, +): Effect.Effect => + managedStackPathsEffect(stateRoot, stackId).pipe( + Effect.map(({ root }) => join(root, "stack.json")), + ); -export const assertManagedStackRoot = ( +export const assertManagedStackRootEffect = ( 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; -}; +): Effect.Effect => + Effect.gen(function* () { + const expected = resolve((yield* managedStackPathsEffect(stateRoot, stackId)).root); + const actual = resolve(stackRoot); + return actual === expected + ? actual + : yield* Effect.fail(new UnsafeManagedStackPathError({ path: stackRoot })); + }); export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => join(workspacePath, ".supabase", "identity.json"); diff --git a/packages/stack/src/managed/store.ts b/packages/stack/src/managed/store.ts index d7b1b42a78..ab046ff048 100644 --- a/packages/stack/src/managed/store.ts +++ b/packages/stack/src/managed/store.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { Effect, FileSystem, Path, PlatformError } from "effect"; +import { join } from "node:path"; +import { Effect, FileSystem, Path, PlatformError, Predicate } from "effect"; import { decodeManagedStackDocument, encodeManagedStackDocument, @@ -7,12 +8,13 @@ import { type ManagedStackDocument, } from "./document.ts"; import { - assertManagedStackRoot, - managedStackDocumentPath, - managedStackPaths, + assertManagedStackRootEffect, + managedStackDocumentPathEffect, + managedStackPathsEffect, managedStacksRoot, - resolveManagedStateRoot, + requireExplicitManagedStateRootEffect, } from "./paths.ts"; +import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./model.ts"; export type ManagedStackListing = | { @@ -33,16 +35,24 @@ export interface StackStore { stackId: string, ) => Effect.Effect< ManagedStackDocument | undefined, - InvalidManagedStackDocumentError | PlatformError.PlatformError + InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError >; readonly list: () => Effect.Effect< ReadonlyArray, - PlatformError.PlatformError + PlatformError.PlatformError | InvalidManagedIdentityError >; readonly write: ( document: ManagedStackDocument, - ) => Effect.Effect; - readonly remove: (stackId: string) => Effect.Effect; + ) => Effect.Effect< + void, + InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError + >; + readonly remove: ( + stackId: string, + ) => Effect.Effect< + void, + InvalidManagedIdentityError | UnsafeManagedStackPathError | PlatformError.PlatformError + >; } const writeDocumentAtomically = ( @@ -77,48 +87,56 @@ const decodeAtPath = ( const content = yield* fs.readFileString(documentPath); const document = yield* decodeManagedStackDocument(documentPath, content); if (document.id !== stackId) { - return yield* new InvalidManagedStackDocumentError({ path: documentPath }); + return yield* Effect.fail(new InvalidManagedStackDocumentError({ path: documentPath })); } return document; }); const isNotFound = (error: PlatformError.PlatformError): boolean => - error.reason._tag === "NotFound"; + Predicate.isTagged(error.reason, "NotFound"); const makeListEntry = ( fs: FileSystem.FileSystem, stateRoot: string, stackId: string, -): Effect.Effect => { - const documentPath = managedStackDocumentPath(stateRoot, stackId); - return decodeAtPath(fs, documentPath, stackId).pipe( - Effect.map((document): ManagedStackListing => ({ id: stackId, status: "healthy", document })), - Effect.catchTag("InvalidManagedStackDocumentError", (cause) => - Effect.succeed({ - id: stackId, - status: "corrupt", - path: documentPath, - cause, - }), - ), - Effect.catchTag("PlatformError", (error) => - isNotFound(error) - ? Effect.succeed(undefined) - : Effect.succeed({ - id: stackId, - status: "corrupt", - path: documentPath, - cause: error, - }), - ), - ); -}; +): Effect.Effect< + ManagedStackListing | undefined, + PlatformError.PlatformError | InvalidManagedIdentityError +> => + Effect.gen(function* () { + const documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); + return yield* decodeAtPath(fs, documentPath, stackId).pipe( + Effect.map((document): ManagedStackListing => ({ id: stackId, status: "healthy", document })), + Effect.catchTag("InvalidManagedStackDocumentError", (cause) => + Effect.succeed({ + id: stackId, + status: "corrupt", + path: documentPath, + cause, + }), + ), + Effect.catchTag("PlatformError", (error) => + isNotFound(error) + ? Effect.succeed(undefined) + : Effect.succeed({ + id: stackId, + status: "corrupt", + path: documentPath, + cause: error, + }), + ), + ); + }); export const makeStackStore = ( stateRoot: string, -): Effect.Effect => { - const resolvedStateRoot = resolveManagedStateRoot({ stateRoot }); - return Effect.gen(function* () { +): Effect.Effect< + StackStore, + InvalidManagedIdentityError | UnsafeManagedStackPathError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const resolvedStateRoot = yield* requireExplicitManagedStateRootEffect(stateRoot); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -126,80 +144,93 @@ export const makeStackStore = ( stackId: string, ): Effect.Effect< ManagedStackDocument | undefined, - InvalidManagedStackDocumentError | PlatformError.PlatformError + InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError > => { - const documentPath = managedStackDocumentPath(resolvedStateRoot, stackId); - return decodeAtPath(fs, documentPath, stackId).pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), - ), - ); + return Effect.gen(function* () { + const documentPath = yield* managedStackDocumentPathEffect(resolvedStateRoot, stackId); + return yield* decodeAtPath(fs, documentPath, stackId).pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + }); }; const list = (): Effect.Effect< ReadonlyArray, - PlatformError.PlatformError + PlatformError.PlatformError | InvalidManagedIdentityError > => Effect.gen(function* () { const stacksRoot = managedStacksRoot(resolvedStateRoot); if (!(yield* fs.exists(stacksRoot))) { return []; } - const names = [ - ...(yield* fs - .readDirectory(stacksRoot) - .pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.succeed>([]) : Effect.fail(error), - ), - )), - ] - .filter((name) => { - try { - managedStackPaths(resolvedStateRoot, name); - return true; - } catch { - return false; - } - }) + const names = yield* fs + .readDirectory(stacksRoot) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.succeed>([]) : Effect.fail(error), + ), + ); + const validNames = yield* Effect.all( + names.map((name) => + managedStackPathsEffect(resolvedStateRoot, name).pipe( + Effect.map(() => name), + Effect.catchTag("InvalidManagedIdentityError", () => Effect.succeed(undefined)), + ), + ), + ); + const sortedNames = validNames + .filter((name): name is string => name !== undefined) .sort((left, right) => left.localeCompare(right)); const entries = yield* Effect.all( - names.map((stackId) => makeListEntry(fs, resolvedStateRoot, stackId)), + sortedNames.map((stackId) => makeListEntry(fs, resolvedStateRoot, stackId)), ); return entries.filter((entry): entry is ManagedStackListing => entry !== undefined); }); const write = ( document: ManagedStackDocument, - ): Effect.Effect => { - const paths = managedStackPaths(resolvedStateRoot, document.id); - return writeDocumentAtomically( - fs, - path, - managedStackDocumentPath(resolvedStateRoot, document.id), - paths.root, - document, - ); - }; + ): Effect.Effect< + void, + InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError + > => + Effect.gen(function* () { + const paths = yield* managedStackPathsEffect(resolvedStateRoot, document.id); + yield* writeDocumentAtomically( + fs, + path, + join(paths.root, "stack.json"), + paths.root, + document, + ); + }); - const remove = (stackId: string): Effect.Effect => { - const paths = managedStackPaths(resolvedStateRoot, stackId); - const safeRoot = assertManagedStackRoot(resolvedStateRoot, stackId, paths.root); - return Effect.gen(function* () { + const remove = ( + stackId: string, + ): Effect.Effect< + void, + InvalidManagedIdentityError | UnsafeManagedStackPathError | PlatformError.PlatformError + > => + Effect.gen(function* () { + const paths = yield* managedStackPathsEffect(resolvedStateRoot, stackId); + const safeRoot = yield* assertManagedStackRootEffect( + resolvedStateRoot, + stackId, + paths.root, + ); if (!(yield* fs.exists(safeRoot))) return; const entries = yield* fs.readDirectory(safeRoot); for (const entry of entries) { if (entry === "stack.json") continue; yield* fs.remove(path.join(safeRoot, entry), { recursive: true, force: true }); } - yield* fs.remove(managedStackDocumentPath(resolvedStateRoot, stackId), { + yield* fs.remove(join(safeRoot, "stack.json"), { recursive: true, force: true, }); yield* fs.remove(safeRoot, { recursive: true, force: true }); }); - }; return { stateRoot: resolvedStateRoot, read, list, write, remove }; }); -}; diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index 8be38e7ce1..839f944362 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -2,9 +2,13 @@ import { NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; +import { createStack as createStackCore, type ResolveConfigEffect } from "./createStack.ts"; +import { toStackHandle, type StackHandle } from "./stackHandle.ts"; +import { toStackError } from "./errors.ts"; import { prefetch as prefetchEffect, + type PrefetchEffectOptions, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; @@ -12,6 +16,10 @@ import { defaultCacheRoot } from "./paths.ts"; import { platformFactory } from "./platform-node.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; +import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; + +const resolveConfigEffectForPlatform: ResolveConfigEffect = (config, options) => + resolveConfigEffect(config, options); /** * The Node daemon bootstrap is deliberately not exported from the package. The conditional Effect @@ -20,16 +28,35 @@ import type { StackConfig } from "./StackConfig.ts"; */ export async function createStack(config?: StackConfig): Promise { - return createStackCore(config, platformFactory); + const runtime = await Effect.runPromise( + selectStackRuntime(config?.mode).pipe(Effect.provide(NodeServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); + const handle = await Effect.runPromise( + createStackCore(config, platformFactory, runtime, resolveConfigEffectForPlatform).pipe( + Effect.provide(NodeServices.layer), + ), + ); + return toStackHandle(handle); } export async function prefetch(options?: PrefetchOptions): Promise { + const runtime = await Effect.runPromise( + selectStackRuntime(options?.mode).pipe(Effect.provide(NodeServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const resolvedOptions: PrefetchEffectOptions = + runtime.mode === "native" + ? { ...options, mode: "native" } + : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( - prefetchEffect(options).pipe( + prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), Effect.provide(NodeServices.layer), ), diff --git a/packages/stack/src/platform-bun.integration.test.ts b/packages/stack/src/platform-bun.integration.test.ts new file mode 100644 index 0000000000..14674b4b8c --- /dev/null +++ b/packages/stack/src/platform-bun.integration.test.ts @@ -0,0 +1,45 @@ +import { Cause, Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { ControlTransport, ControlTransportError } from "./managed/control.ts"; + +const isBun = typeof Bun !== "undefined"; + +describe("Bun control transport", () => { + (isBun ? test : test.skip)("classifies an owner status timeout as transport", async () => { + const { controlTransportLayer } = await import("./platform-bun.ts"); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => + new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + try { + const port = server.port; + expect(port).toBeTypeOf("number"); + if (port === undefined) return; + const endpoint = { + hostname: "127.0.0.1", + port, + url: `http://127.0.0.1:${port}`, + }; + const exit = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( + Effect.provide(controlTransportLayer), + Effect.exit, + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.squash(exit.cause); + expect(failure).toBeInstanceOf(ControlTransportError); + if (failure instanceof ControlTransportError) expect(failure.reason).toBe("transport"); + } + } finally { + await server.stop(true); + } + }); +}); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts index ebbeee33fc..c2cfc4b87b 100644 --- a/packages/stack/src/platform-bun.ts +++ b/packages/stack/src/platform-bun.ts @@ -21,6 +21,11 @@ const errorCode = (cause: unknown): string | undefined => { return undefined; }; +const isDefinitivelyUnreachable = (cause: unknown): boolean => { + const code = errorCode(cause); + return code === "ECONNREFUSED" || code === "ConnectionRefused"; +}; + const controlTransport: ControlTransport["Service"] = { bind: (endpoint: ControlEndpoint, ownerStatus: () => ControlOwnerStatus, onStop: () => void) => // Bun.serve starts synchronously inside BunHttpServer.make, before that @@ -34,6 +39,10 @@ const controlTransport: ControlTransport["Service"] = { const server = yield* BunHttpServer.make({ hostname: endpoint.hostname, port: endpoint.port, + // Stack lifecycle requests can legitimately take up to the configured + // readiness deadline. Bun's 10-second default would otherwise close + // the control connection while the stack continues starting. + idleTimeout: 0, disablePreemptiveShutdown: true, routes: { [CONTROL_STATUS_PATH]: { @@ -79,43 +88,47 @@ const controlTransport: ControlTransport["Service"] = { ), read: (endpoint: ControlEndpoint) => Effect.tryPromise({ - try: async () => { - const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, { - signal: AbortSignal.timeout(500), + try: (signal) => + fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, { + signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), // One-shot connection: a pooled keep-alive connection would let a // closed listener keep answering status probes while the probes // themselves keep the connection alive. headers: { connection: "close" }, - }); - if (!response.ok) throw new Error(`Control status request returned ${response.status}`); - return await response.json(); - }, + }).then((response) => { + if (!response.ok) throw new Error(`Control status request returned ${response.status}`); + return response.json(); + }), catch: (cause) => { if ( cause instanceof SyntaxError || - (cause instanceof Error && - cause.message.startsWith("Control status request returned") && - !cause.message.endsWith(" 404")) + (cause instanceof Error && cause.message.startsWith("Control status request returned")) ) { return new ControlProtocolError({ endpoint, cause }); } - if (cause instanceof Error && cause.message.endsWith(" 404")) { - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); - } - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); + return new ControlTransportError({ + endpoint, + reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", + cause, + }); }, }), requestStop: (endpoint: ControlEndpoint) => Effect.tryPromise({ - try: async () => { - const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, { + try: (signal) => + fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, { method: "POST", - signal: AbortSignal.timeout(500), + signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), headers: { connection: "close" }, - }); - if (!response.ok) throw new Error(`Control stop request returned ${response.status}`); - }, - catch: (cause) => new ControlTransportError({ endpoint, reason: "unreachable", cause }), + }).then((response) => { + if (!response.ok) throw new Error(`Control stop request returned ${response.status}`); + }), + catch: (cause) => + new ControlTransportError({ + endpoint, + reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", + cause, + }), }), }; diff --git a/packages/stack/src/platform-node.integration.test.ts b/packages/stack/src/platform-node.integration.test.ts new file mode 100644 index 0000000000..e26c6d060f --- /dev/null +++ b/packages/stack/src/platform-node.integration.test.ts @@ -0,0 +1,182 @@ +import { Cause, Effect, Exit } from "effect"; +import { createServer, type Server } from "node:http"; +import type { Socket } from "node:net"; +import { describe, expect, test } from "vitest"; +import { + ControlProtocolError, + ControlTransport, + ControlTransportError, + type ControlEndpoint, +} from "./managed/control.ts"; +import { controlTransportLayer } from "./platform-node.ts"; + +const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; + +const listen = (server: Server): Promise => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Expected TCP address")); + return; + } + resolve({ + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }); + }); + }); + +const close = (server: Server, sockets: ReadonlySet): Promise => + new Promise((resolve, reject) => { + for (const socket of sockets) socket.destroy(); + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + +const withTimeout = async (promise: Promise, timeoutMs = 5_000): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +}; + +const runRead = (endpoint: ControlEndpoint) => + Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( + Effect.provide(controlTransportLayer), + Effect.exit, + ), + ); + +const runStop = (endpoint: ControlEndpoint) => + Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => transport.requestStop(endpoint)).pipe( + Effect.provide(controlTransportLayer), + Effect.exit, + ), + ); + +const expectTypedFailure = ( + exit: Exit.Exit, + error: new (...args: any[]) => E, +) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(error); +}; + +describe("Node control transport", () => { + test("maps post-header resets from owner and stop probes to typed failures", async () => { + let requestCount = 0; + let resolveRequest!: () => void; + let requestReady = Promise.resolve(); + const sockets = new Set(); + const server = createServer((_request, response) => { + sockets.add(response.socket!); + response.socket!.once("close", () => sockets.delete(response.socket!)); + resolveRequest(); + response.writeHead(200, { "content-type": "application/json" }); + response.flushHeaders(); + response.write(requestCount++ === 0 ? '{"protocolVersion":' : "{", () => response.destroy()); + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + + const prepareRequest = () => { + requestReady = new Promise((resolve) => { + resolveRequest = resolve; + }); + }; + + prepareRequest(); + const readExitPromise = runRead(endpoint); + await requestReady; + const readExit = await withTimeout(readExitPromise); + expectTypedFailure(readExit, ControlTransportError); + + prepareRequest(); + const stopExitPromise = runStop(endpoint); + await requestReady; + const stopExit = await withTimeout(stopExitPromise); + expectTypedFailure(stopExit, ControlTransportError); + } finally { + await close(server, sockets); + } + }); + + test("classifies control timeouts as transport instead of absence", async () => { + const sockets = new Set(); + const server = createServer((_request, response) => { + sockets.add(response.socket!); + response.socket!.once("close", () => sockets.delete(response.socket!)); + // Keep the status request open until the client-side deadline expires. + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + // This is only a deadlock guard; the assertion is about the typed + // failure, not wall-clock scheduling on a loaded runner. + const readExit = await withTimeout(runRead(endpoint), 10_000); + expectTypedFailure(readExit, ControlTransportError); + if (Exit.isFailure(readExit)) { + expect(Cause.squash(readExit.cause)).toMatchObject({ reason: "transport" }); + } + + const stopExit = await withTimeout(runStop(endpoint), 10_000); + expectTypedFailure(stopExit, ControlTransportError); + if (Exit.isFailure(stopExit)) { + expect(Cause.squash(stopExit.cause)).toMatchObject({ reason: "transport" }); + } + } finally { + await close(server, sockets); + } + }); + + test("bounds an oversized owner status and closes the exact connection", async () => { + let resolveRequest!: () => void; + const requestReady = new Promise((resolve) => { + resolveRequest = resolve; + }); + let resolveClosed!: () => void; + const connectionClosed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const sockets = new Set(); + const server = createServer((_request, response) => { + const socket = response.socket; + if (socket === null) throw new Error("Expected request socket"); + sockets.add(socket); + socket.once("close", () => { + sockets.delete(socket); + resolveClosed(); + }); + resolveRequest(); + response.writeHead(200, { "content-type": "application/json" }); + response.write("x".repeat(MAX_CONTROL_RESPONSE_BYTES + 1)); + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + const exitPromise = runRead(endpoint); + await requestReady; + const exit = await withTimeout(exitPromise); + expectTypedFailure(exit, ControlProtocolError); + await withTimeout(connectionClosed); + } finally { + await close(server, sockets); + } + }); +}); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts index 583db3c324..a9c769ee67 100644 --- a/packages/stack/src/platform-node.ts +++ b/packages/stack/src/platform-node.ts @@ -15,6 +15,9 @@ import { type ControlOwnerStatus, type ControlEndpoint, } from "./managed/control.ts"; + +const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; + const errorCode = (cause: unknown): string | undefined => { if (typeof cause !== "object" || cause === null) return undefined; if ("code" in cause && typeof cause.code === "string") return cause.code; @@ -22,6 +25,11 @@ const errorCode = (cause: unknown): string | undefined => { return undefined; }; +const isDefinitivelyUnreachable = (cause: unknown): boolean => { + const code = errorCode(cause); + return code === "ECONNREFUSED"; +}; + const closeControlServer = (server: Http.Server): Effect.Effect => Effect.callback((resume) => { if (!server.listening) { @@ -32,6 +40,24 @@ const closeControlServer = (server: Http.Server): Effect.Effect => return Effect.void; }); +const readError = ( + endpoint: ControlEndpoint, + cause: unknown, +): ControlTransportError | ControlProtocolError => { + if ( + cause instanceof SyntaxError || + (cause instanceof Error && + cause.message === `Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`) || + (cause instanceof Error && cause.message.startsWith("Control status request returned")) + ) { + return new ControlProtocolError({ endpoint, cause }); + } + if (isDefinitivelyUnreachable(cause)) { + return new ControlTransportError({ endpoint, reason: "unreachable", cause }); + } + return new ControlTransportError({ endpoint, reason: "transport", cause }); +}; + const controlTransport: ControlTransport["Service"] = { bind: (endpoint: ControlEndpoint, ownerStatus: () => ControlOwnerStatus, onStop: () => void) => { const rawServer = createServer((request, response) => { @@ -68,106 +94,229 @@ const controlTransport: ControlTransport["Service"] = { ); }, read: (endpoint: ControlEndpoint) => - Effect.tryPromise({ - try: async () => { - const requestStatus = (host: string) => - new Promise((resolve, reject) => { - const request = Http.request( - { - host, - port: endpoint.port, - path: CONTROL_STATUS_PATH, - method: "GET", - // One-shot connection: a pooled keep-alive connection would - // let a closed listener keep answering status probes while - // the probes themselves keep the connection alive. - agent: false, - }, - (response) => { - let body = ""; - response.setEncoding("utf8"); - response.on("data", (chunk: string) => { - body += chunk; - }); - response.on("end", () => { - if ((response.statusCode ?? 500) < 200 || (response.statusCode ?? 500) >= 300) { - reject( - new Error(`Control status request returned ${response.statusCode ?? 500}`), - ); - return; - } - try { - resolve(JSON.parse(body)); - } catch (cause) { - reject(cause); - } - }); - }, - ); - request.setTimeout(500, () => - request.destroy(new Error("Control status request timed out")), - ); - request.once("error", reject); - request.end(); - }); - return await requestStatus("127.0.0.1"); - }, - catch: (cause) => { - const code = errorCode(cause); - if ( - code === "ECONNREFUSED" || - code === "ECONNRESET" || - code === "EHOSTUNREACH" || - (cause instanceof Error && cause.message === "Control status request timed out") - ) { - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); - } - if ( - cause instanceof SyntaxError || - (cause instanceof Error && - cause.message.startsWith("Control status request returned") && - !cause.message.endsWith(" 404")) - ) { - return new ControlProtocolError({ endpoint, cause }); - } - if (cause instanceof Error && cause.message.endsWith(" 404")) { - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); + Effect.callback((resume) => { + let response: Http.IncomingMessage | undefined; + let onData: ((chunk: string) => void) | undefined; + let onEnd: (() => void) | undefined; + let onResponseError: ((cause: Error) => void) | undefined; + let onResponseAborted: (() => void) | undefined; + let onResponseClose: (() => void) | undefined; + let settled = false; + let cleanup = () => {}; + let dispose = () => {}; + const finish = (effect: Effect.Effect, shouldDispose = false) => { + if (settled) return; + settled = true; + cleanup(); + if (shouldDispose) dispose(); + resume(effect); + }; + const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); + const request = Http.request( + { + host: "127.0.0.1", + port: endpoint.port, + path: CONTROL_STATUS_PATH, + method: "GET", + // One-shot connection: a pooled keep-alive connection would let a + // closed listener keep answering status probes while the probes + // themselves keep the connection alive. + agent: false, + }, + (incoming) => { + response = incoming; + let body = ""; + let bodyBytes = 0; + let ended = false; + let responseAborted = false; + onData = (chunk) => { + bodyBytes += Buffer.byteLength(chunk, "utf8"); + if (bodyBytes > MAX_CONTROL_RESPONSE_BYTES) { + finish( + Effect.fail( + new Error(`Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`), + ), + true, + ); + return; + } + body += chunk; + }; + onEnd = () => { + ended = true; + if ((incoming.statusCode ?? 500) < 200 || (incoming.statusCode ?? 500) >= 300) { + finish( + Effect.fail( + new Error(`Control status request returned ${incoming.statusCode ?? 500}`), + ), + true, + ); + return; + } + try { + finish(Effect.succeed(JSON.parse(body))); + } catch (cause) { + finish(Effect.fail(cause), true); + } + }; + onResponseError = (cause) => finish(Effect.fail(cause), true); + onResponseAborted = () => { + responseAborted = true; + }; + onResponseClose = () => { + if (responseAborted || !ended) { + finish(Effect.fail(new Error("Control status response closed before end")), true); + } + }; + incoming.setEncoding("utf8"); + incoming.on("data", onData); + incoming.once("end", onEnd); + incoming.once("error", onResponseError); + incoming.once("aborted", onResponseAborted); + incoming.once("close", onResponseClose); + }, + ); + dispose = () => { + response?.destroy(); + request.destroy(); + }; + cleanup = () => { + request.removeListener("error", onRequestError); + if (response !== undefined) { + if (onData !== undefined) response.removeListener("data", onData); + if (onEnd !== undefined) response.removeListener("end", onEnd); + if (onResponseError !== undefined) response.removeListener("error", onResponseError); + if (onResponseAborted !== undefined) { + response.removeListener("aborted", onResponseAborted); + } + if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); } - return new ControlTransportError({ endpoint, reason: "transport", cause }); - }, - }), + }; + request.once("error", onRequestError); + request.end(); + return Effect.callback((resumeCancellation) => { + const onClose = () => { + cleanup(); + resumeCancellation(Effect.void); + }; + settled = true; + request.once("close", onClose); + dispose(); + return Effect.sync(() => { + request.removeListener("close", onClose); + cleanup(); + }); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: 500, + orElse: () => Effect.fail(new Error("Control status request timed out")), + }), + Effect.mapError((cause) => readError(endpoint, cause)), + ), requestStop: (endpoint: ControlEndpoint) => - Effect.tryPromise({ - try: async () => { - await new Promise((resolve, reject) => { - const request = Http.request( - { - host: endpoint.hostname, - port: endpoint.port, - path: CONTROL_STOP_PATH, - method: "POST", - agent: false, - }, - (response) => { - response.resume(); - response.once("end", () => { - if ((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300) { - resolve(); - } else { - reject(new Error(`Control stop request returned ${response.statusCode ?? 500}`)); - } - }); - }, - ); - request.setTimeout(500, () => - request.destroy(new Error("Control stop request timed out")), - ); - request.once("error", reject); - request.end(); + Effect.callback((resume) => { + let response: Http.IncomingMessage | undefined; + let onEnd: (() => void) | undefined; + let onResponseError: ((cause: Error) => void) | undefined; + let onResponseAborted: (() => void) | undefined; + let onResponseClose: (() => void) | undefined; + let settled = false; + let cleanup = () => {}; + let dispose = () => {}; + const finish = (effect: Effect.Effect, shouldDispose = false) => { + if (settled) return; + settled = true; + cleanup(); + if (shouldDispose) dispose(); + resume(effect); + }; + const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); + const request = Http.request( + { + host: endpoint.hostname, + port: endpoint.port, + path: CONTROL_STOP_PATH, + method: "POST", + agent: false, + }, + (incoming) => { + response = incoming; + let ended = false; + let responseAborted = false; + onEnd = () => { + ended = true; + if ((incoming.statusCode ?? 500) >= 200 && (incoming.statusCode ?? 500) < 300) { + finish(Effect.void); + } else { + finish( + Effect.fail( + new Error(`Control stop request returned ${incoming.statusCode ?? 500}`), + ), + true, + ); + } + }; + onResponseError = (cause) => finish(Effect.fail(cause), true); + onResponseAborted = () => { + responseAborted = true; + }; + onResponseClose = () => { + if (responseAborted || !ended) { + finish(Effect.fail(new Error("Control stop response closed before end")), true); + } + }; + incoming.once("end", onEnd); + incoming.once("error", onResponseError); + incoming.once("aborted", onResponseAborted); + incoming.once("close", onResponseClose); + incoming.resume(); + }, + ); + dispose = () => { + response?.destroy(); + request.destroy(); + }; + cleanup = () => { + request.removeListener("error", onRequestError); + if (response !== undefined) { + if (onEnd !== undefined) response.removeListener("end", onEnd); + if (onResponseError !== undefined) response.removeListener("error", onResponseError); + if (onResponseAborted !== undefined) { + response.removeListener("aborted", onResponseAborted); + } + if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); + } + }; + request.once("error", onRequestError); + request.end(); + return Effect.callback((resumeCancellation) => { + const onClose = () => { + cleanup(); + resumeCancellation(Effect.void); + }; + settled = true; + request.once("close", onClose); + dispose(); + return Effect.sync(() => { + request.removeListener("close", onClose); + cleanup(); }); - }, - catch: (cause) => new ControlTransportError({ endpoint, reason: "unreachable", cause }), - }), + }); + }).pipe( + Effect.timeoutOrElse({ + duration: 500, + orElse: () => Effect.fail(new Error("Control stop request timed out")), + }), + Effect.mapError( + (cause) => + new ControlTransportError({ + endpoint, + reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", + cause, + }), + ), + ), }; export const controlTransportLayer = Layer.succeed(ControlTransport, controlTransport); diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts index b68d4b5f4d..c4a26af9fc 100644 --- a/packages/stack/src/prefetch.ts +++ b/packages/stack/src/prefetch.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; -import type { ChecksumMismatchError } from "./errors.ts"; -import type { DockerPullError } from "./errors.ts"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; +import type { StackPreparationError } from "./StackPreparation.ts"; import { type PreparedStackArtifacts, type ServiceResolution, @@ -9,7 +9,18 @@ import { import { StackPreparation } from "./StackPreparation.ts"; import type { ServiceName } from "./ServiceName.ts"; -export interface PrefetchOptions extends StackPreparationInput {} +export interface PrefetchOptions { + readonly versions?: StackPreparationInput["versions"]; + readonly services?: StackPreparationInput["services"]; + readonly enabledServices?: StackPreparationInput["enabledServices"]; + readonly mode?: "native" | "docker"; +} + +export type PrefetchEffectOptions = Omit & + ( + | { readonly mode?: "native"; readonly containerRuntime?: never } + | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } + ); export type PrefetchResult = Partial>; @@ -17,9 +28,11 @@ const toPrefetchResult = (artifacts: PreparedStackArtifacts): PrefetchResult => artifacts.resolutions; export const prefetch = ( - options?: PrefetchOptions, -): Effect.Effect => + options?: PrefetchEffectOptions, +): Effect.Effect => Effect.gen(function* () { const preparation = yield* StackPreparation; - return yield* preparation.prepare(options).pipe(Effect.map(toPrefetchResult)); + const input: StackPreparationInput = + options?.mode === "docker" ? options : { ...options, mode: "native" }; + return yield* preparation.prepare(input).pipe(Effect.map(toPrefetchResult)); }); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index 1cf509d074..bb29935b41 100644 --- a/packages/stack/src/prefetch.unit.test.ts +++ b/packages/stack/src/prefetch.unit.test.ts @@ -1,26 +1,36 @@ import { describe, expect, test } from "vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Predicate, + Queue, + Result, + Sink, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import { DockerPullError } from "./errors.ts"; +import { BinaryNotFoundError, DockerPullError } from "./errors.ts"; import { prefetch } from "./prefetch.ts"; import { ServiceDownloadFinished, ServiceDownloadStarted, + PreparationCompleted, StackPreparation, } from "./StackPreparation.ts"; -import { prepareAssetsWithDependencies } from "./StackPreparation.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; const encoder = new TextEncoder(); -const defaultAuthEcrImage = `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; -const defaultAuthDockerHubImage = `supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; -const defaultAuthGhcrImage = `ghcr.io/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; +const defaultAuthGhcrImage = `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`; interface SpawnResult { readonly exitCode: number; readonly stderr?: ReadonlyArray; + readonly defect?: unknown; } function mockSequenceSpawner(results: ReadonlyArray) { @@ -32,20 +42,19 @@ function mockSequenceSpawner(results: ReadonlyArray) { ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; + const standardCommand = Predicate.isTagged(command, "StandardCommand"); + const cmd = standardCommand ? command.command : ""; + const args = standardCommand ? command.args : []; spawned.push({ command: cmd, args }); const result = results[index] ?? { exitCode: 0 }; index += 1; + if (result.defect !== undefined) { + return yield* Effect.die(result.defect); + } const exitDeferred = yield* Deferred.make(); - yield* Effect.forkDetach( - Effect.andThen( - Effect.sleep("1 millis"), - Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)), - ), - ); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(2000 + index), @@ -72,7 +81,7 @@ function mockSequenceSpawner(results: ReadonlyArray) { } describe("prefetch", () => { - test("prefetches all services by default", async () => { + test("prefetches every native-capable service by default in native mode", async () => { const resolver = mockBinaryResolver(); const spawner = mockSequenceSpawner( Array.from({ length: SERVICE_NAMES.length }, () => ({ @@ -87,53 +96,226 @@ describe("prefetch", () => { const result = await Effect.runPromise(prefetch().pipe(Effect.provide(layer))); - expect(Object.keys(result).sort()).toEqual([...SERVICE_NAMES].sort()); + expect(Object.keys(result).sort()).toEqual(["auth", "postgres", "postgrest"]); }); - test("falls back to Docker Hub after ECR rate limiting", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); + test("limits concurrent image preparation to four services", async () => { + const started = await Effect.runPromise(Queue.unbounded()); + const release = await Effect.runPromise(Deferred.make()); + const services = ["postgres", "mailpit", "edge-runtime", "realtime", "pooler"] as const; + let pullStarts = 0; + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const standardCommand = Predicate.isTagged(command, "StandardCommand"); + const args = standardCommand ? command.args : []; + const image = args[1] ?? "unknown"; + if (args[0] === "pull") { + pullStarts += 1; + yield* Queue.offer(started, image); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(3000 + pullStarts), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(release).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + isRunning: Effect.succeed(true), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(2000), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + const layer = StackPreparation.layer.pipe( + Layer.provide(mockBinaryResolver().layer), + Layer.provide(spawner), + ); + + const preparation = Effect.runFork( + prefetch({ mode: "docker", containerRuntime: "docker", services }).pipe( + Effect.provide(layer), + ), + ); + await Effect.runPromise( + Effect.all( + Array.from({ length: 4 }, () => Queue.take(started)), + { discard: true }, + ), + ); + expect(pullStarts).toBe(4); + + await Effect.runPromise(Deferred.succeed(release, undefined)); + await Effect.runPromise(Fiber.join(preparation)); + expect(pullStarts).toBe(5); + }); + + test("marks a Podman daemon disconnect on DockerPullError", async () => { + const resolver = mockBinaryResolver(); + // One image inspect followed by one canonical pull. Preparation must fail + // rather than defer the pull to startup. + const spawner = mockSequenceSpawner([ + { exitCode: 1, stderr: ["not found"] }, + { exitCode: 1, stderr: ["Cannot connect to Podman"] }, + ]); + + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const error = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "podman", services: ["auth"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(DockerPullError); + if (!(error instanceof DockerPullError)) throw error; + expect(error.daemonDown).toBe(true); + }); + + test("preserves unexpected image pull defects", async () => { + const defect = new Error("container runtime callback defect"); const spawner = mockSequenceSpawner([ - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 0 }, + { exitCode: 1, stderr: ["not found"] }, + { exitCode: 0, defect }, ]); + const layer = StackPreparation.layer.pipe( + Layer.provide(mockBinaryResolver().layer), + Layer.provide(spawner.layer), + ); + + const exit = await Effect.runPromiseExit( + prefetch({ mode: "docker", containerRuntime: "docker", services: ["auth"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const found = Cause.findDefect(exit.cause); + expect(Result.isSuccess(found)).toBe(true); + if (Result.isSuccess(found)) { + expect(found.success).toBe(defect); + } + } + }); + test("prefetching one service includes its required preparation dependencies", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), Layer.provide(spawner.layer), ); const result = await Effect.runPromise( - prefetch({ - mode: "docker", - services: ["auth"], + prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(Object.keys(result).sort()).toEqual(["postgres", "postgrest"]); + }); + + test.each([ + ["storage", "docker", ["imgproxy", "postgres", "storage"]], + ["imgproxy", "podman", ["imgproxy", "postgres", "storage"]], + ["vector", "docker", ["analytics", "postgres", "vector"]], + ] as const)( + "prefetching %s includes every service it can start through the graph", + async (service, containerRuntime, expected) => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }, { exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime, services: [service] }).pipe( + Effect.provide(layer), + ), + ); + + expect(Object.keys(result).sort()).toEqual(expected); + expect(spawner.spawned).toHaveLength(expected.length); + expect(spawner.spawned.every(({ command }) => command === containerRuntime)).toBe(true); + expect(spawner.spawned).toContainEqual({ + command: containerRuntime, + args: ["image", "inspect", `ghcr.io/supabase/cli/${service}:${DEFAULT_VERSIONS[service]}`], + }); + }, + ); + + test("does not prepare dependencies that are disabled in the stack", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }, { exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const preparation = yield* StackPreparation; + return yield* preparation.prepare({ + mode: "docker", + containerRuntime: "docker", + services: ["studio"], + enabledServices: ["postgres", "pgmeta", "studio"], + }); }).pipe(Effect.provide(layer)), ); - expect(result.auth).toEqual({ + expect(Object.keys(result.resolutions).sort()).toEqual(["pgmeta", "postgres", "studio"]); + }); + + test("Docker mode uses Docker when a native artifact is unavailable", async () => { + const resolver = mockBinaryResolver({ + binaries: { postgres: "/cache/postgres/native" }, + }); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(result.postgrest).toEqual({ type: "docker", - image: defaultAuthDockerHubImage, + image: `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, }); - expect( - spawner.spawned.filter((record) => record.args[0] === "pull").map((record) => record.args[1]), - ).toEqual([defaultAuthEcrImage, defaultAuthEcrImage, defaultAuthDockerHubImage]); }); - test("falls back to GHCR after ECR and Docker Hub fail", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - const spawner = mockSequenceSpawner([ - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 0 }, - ]); - + test("Docker preparation applies the catalog v prefix to bare versions", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), Layer.provide(spawner.layer), @@ -142,163 +324,191 @@ describe("prefetch", () => { const result = await Effect.runPromise( prefetch({ mode: "docker", - services: ["auth"], + containerRuntime: "docker", + services: ["postgrest"], + versions: { postgrest: "16.1" }, }).pipe(Effect.provide(layer)), ); - expect(result.auth).toEqual({ + expect(result.postgrest).toEqual({ type: "docker", - image: defaultAuthGhcrImage, + image: "ghcr.io/supabase/cli/postgrest:v16.1", }); - expect( - spawner.spawned.filter((record) => record.args[0] === "pull").map((record) => record.args[1]), - ).toEqual([ - defaultAuthEcrImage, - defaultAuthDockerHubImage, - defaultAuthDockerHubImage, - defaultAuthGhcrImage, - ]); }); - test("preparation fails with DockerPullError when all registry candidates fail", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - // 3 image inspects (not cached locally) followed by a non-retryable pull for - // each registry candidate (ECR, Docker Hub, GHCR). "manifest unknown" is not a - // retryable pattern, so each candidate gets exactly one pull attempt: 3 + 3 = 6 - // spawns. With the whole fallback chain failing, preparation must fail rather - // than defer the pull to startup. - const spawner = mockSequenceSpawner([ - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 1, stderr: ["manifest unknown"] }, - ]); + test("native preparation applies the catalog v prefix before binary resolution", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + await Effect.runPromise( + prefetch({ + mode: "native", + services: ["postgrest"], + versions: { postgrest: "16.1" }, + }).pipe(Effect.provide(layer)), + ); + expect(resolver.resolved).toContainEqual({ service: "postgrest", version: "v16.1" }); + }); + + test("native mode rejects services that have no native runtime", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), Layer.provide(spawner.layer), ); const error = await Effect.runPromise( - prefetch({ mode: "docker", services: ["auth"] }).pipe(Effect.provide(layer), Effect.flip), + prefetch({ mode: "native", services: ["edge-runtime"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), ); - expect(error).toBeInstanceOf(DockerPullError); - // Guard the spawn-count assumption above: if the retry/candidate logic changes - // so more spawns occur, the mock would default the extras to success and mask - // the failure. Assert the exact count so that regresses loudly instead. - expect(spawner.spawned).toHaveLength(6); + expect(error).toBeInstanceOf(BinaryNotFoundError); }); - test("does not report downloading when the docker image is already cached locally", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); + test("native mode does not fall back when a native artifact is unavailable", async () => { + const resolver = mockBinaryResolver({ failServices: ["postgrest"] }); const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const events: string[] = []; + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const error = await Effect.runPromise( + prefetch({ mode: "native", services: ["postgrest"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(BinaryNotFoundError); + expect(spawner.spawned).toEqual([]); + }); + + test("prefetches pgmeta using its published container tag", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + const result = await Effect.runPromise( - Effect.gen(function* () { - const resolverService = yield* BinaryResolver; - const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; - const artifacts = yield* prepareAssetsWithDependencies( - resolverService, - spawnerService, - { - mode: "docker", - services: ["auth"], - }, - (event) => - Effect.sync(() => { - if ( - event instanceof ServiceDownloadStarted || - event instanceof ServiceDownloadFinished - ) { - events.push(event._tag); - } - }), - ); - return artifacts.resolutions; - }).pipe(Effect.provide(resolver.layer), Effect.provide(spawner.layer)), + prefetch({ mode: "docker", containerRuntime: "docker", services: ["pgmeta"] }).pipe( + Effect.provide(layer), + ), ); - expect(result.auth).toEqual({ + expect(result.pgmeta).toEqual({ type: "docker", - image: defaultAuthEcrImage, + image: "ghcr.io/supabase/cli/pgmeta:v0.98.0", }); - expect(events).toEqual([]); }); - test("reports per-service download finished events as each service completes", async () => { - const resolver = mockBinaryResolver({ - downloadedServices: ["postgres", "postgrest", "auth"], - downloadDelaysMs: { - postgres: 10, - auth: 30, - postgrest: 50, - }, - }); - const events: string[] = []; - - await Effect.runPromise( + test("does not report downloading when the docker image is already cached locally", async () => { + const resolver = mockBinaryResolver({ failServices: ["auth"] }); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + const result = await Effect.runPromise( Effect.gen(function* () { - const resolverService = yield* BinaryResolver; - const artifacts = yield* prepareAssetsWithDependencies( - resolverService, - {} as ChildProcessSpawner.ChildProcessSpawner["Service"], - { - mode: "native", - services: ["postgres", "postgrest", "auth"], - }, - (event) => - Effect.sync(() => { - switch (event._tag) { - case "ServiceDownloadStarted": - case "ServiceDownloadFinished": - events.push(`${event._tag}:${event.service}`); - break; - case "PreparationCompleted": - events.push("PreparationCompleted"); - break; - } - }), + const preparation = yield* StackPreparation; + const streamEvents = yield* preparation + .prepareEvents({ mode: "docker", containerRuntime: "docker", services: ["auth"] }) + .pipe(Stream.runCollect); + const downloadEvents = streamEvents.flatMap((event) => + event instanceof ServiceDownloadStarted || event instanceof ServiceDownloadFinished + ? [ + Predicate.isTagged(event, "ServiceDownloadStarted") + ? "ServiceDownloadStarted" + : "ServiceDownloadFinished", + ] + : [], ); - expect(Object.keys(artifacts.resolutions)).toEqual(["postgres", "postgrest", "auth"]); - }).pipe(Effect.provide(resolver.layer)), + const completed = streamEvents.find((event) => event instanceof PreparationCompleted); + expect(downloadEvents).toEqual([]); + return completed instanceof PreparationCompleted ? completed.artifacts.resolutions : {}; + }).pipe(Effect.provide(layer)), ); - expect(events.slice(0, 3)).toEqual([ - "ServiceDownloadStarted:postgres", - "ServiceDownloadStarted:postgrest", - "ServiceDownloadStarted:auth", - ]); - expect(events.slice(3, 6).sort()).toEqual([ - "ServiceDownloadFinished:auth", - "ServiceDownloadFinished:postgres", - "ServiceDownloadFinished:postgrest", - ]); - expect(events.at(-1)).toBe("PreparationCompleted"); + expect(result.auth).toEqual({ + type: "docker", + image: defaultAuthGhcrImage, + }); }); - test("uses docker for edge-runtime in auto mode even when a native binary exists", async () => { + test("uses Docker for every service in Docker mode", async () => { const resolver = mockBinaryResolver(); const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); const result = await Effect.runPromise( Effect.gen(function* () { - const resolverService = yield* BinaryResolver; - const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; - const artifacts = yield* prepareAssetsWithDependencies(resolverService, spawnerService, { - mode: "auto", + const preparation = yield* StackPreparation; + const artifacts = yield* preparation.prepare({ + mode: "docker", + containerRuntime: "docker", services: ["edge-runtime"], }); return artifacts.resolutions; - }).pipe(Effect.provide(resolver.layer), Effect.provide(spawner.layer)), + }).pipe(Effect.provide(layer)), ); expect(result["edge-runtime"]).toEqual({ type: "docker", - image: `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + image: `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, }); expect(resolver.resolved).toEqual([]); }); + + test("concurrent prefetches share one materialization and return the same result", async () => { + const [result, resolved] = await Effect.runPromise( + Effect.gen(function* () { + const preparationStarted = yield* Deferred.make(); + const releasePreparation = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" + ? Deferred.succeed(preparationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePreparation)), + ) + : Effect.void, + }); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(mockSequenceSpawner([]).layer), + ); + return yield* Effect.gen(function* () { + const first = yield* prefetch({ mode: "native", services: ["auth"] }).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(preparationStarted); + const second = yield* prefetch({ mode: "native", services: ["auth"] }).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releasePreparation, undefined); + return [ + yield* Effect.all([Fiber.join(first), Fiber.join(second)]), + resolver.resolved, + ] as const; + }).pipe(Effect.provide(layer)); + }), + ); + + expect(result[0]).toEqual(result[1]); + expect(resolved.filter(({ service }) => service === "auth")).toHaveLength(1); + }); }); diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index e4039a989e..6f4bcfcf03 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerAnalyticsOptions { +interface DockerAnalyticsOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly hostPort: number; @@ -69,23 +73,13 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic } return dockerRunService({ + runtime: opts.runtime, name: "analytics", identity: opts.identity, image: opts.image, networkArgs: dockerPortMapArgs(opts.platformOs, [ { host: opts.hostPort, container: ANALYTICS_CONTAINER_PORT }, ]), - entrypoint: "sh", - cmd: [ - "-c", - // migrate && start: a failed migrate exits the container and the - // unless-stopped restart retries until the db is ready (supabase/cli#6088). - `cat <<'EOF' > /tmp/run.sh && sh /tmp/run.sh -./logflare eval Logflare.Release.migrate && -./logflare start --sname logflare -EOF -`, - ], env, dependencies: opts.dependencies, healthCheck: analyticsHealthCheck(opts.hostPort), diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index 28fa6ddf98..111c07335b 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -2,7 +2,11 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; interface AuthServiceOptions { readonly dbPort: number; @@ -22,7 +26,7 @@ interface NativeAuthOptions extends AuthServiceOptions { readonly binPath: string; } -interface DockerAuthOptions extends AuthServiceOptions { +interface DockerAuthOptions extends AuthServiceOptions, ContainerRuntimeOptions { readonly image: string; readonly dbHost: string; readonly platformOs: string; @@ -71,7 +75,7 @@ const authHealthCheck = (port: number) => ({ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ name: "auth", - command: `${opts.binPath}/auth`, + command: `${opts.binPath}/bin/auth`, env: authEnv(opts), dependencies: opts.dependencies, healthCheck: authHealthCheck(opts.authPort), @@ -82,6 +86,7 @@ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { const env = authEnv(opts, opts.dbHost); return dockerRunService({ + runtime: opts.runtime, name: "auth", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/docker-cleanup.ts b/packages/stack/src/services/docker-cleanup.ts index c96c7af6b9..0e9b5a2a8a 100644 --- a/packages/stack/src/services/docker-cleanup.ts +++ b/packages/stack/src/services/docker-cleanup.ts @@ -1,11 +1,15 @@ import type { ExternalCleanupAction } from "@supabase/process-compose"; import { execFileSync } from "node:child_process"; import { Effect } from "effect"; +import type { ContainerRuntime } from "../ContainerRuntime.ts"; -export const dockerServiceCleanup = (containerName: string): Effect.Effect => +export const dockerServiceCleanup = ( + runtime: ContainerRuntime, + containerName: string, +): Effect.Effect => Effect.sync(() => { try { - execFileSync("docker", ["rm", "-f", containerName], { + execFileSync(runtime, ["rm", "-f", containerName], { stdio: "ignore", timeout: 5_000, }); @@ -13,11 +17,12 @@ export const dockerServiceCleanup = (containerName: string): Effect.Effect }); export const dockerServiceOrphanCleanup = ( + runtime: ContainerRuntime, containerName: string, ): ReadonlyArray => [ { _tag: "RunCommand", - executable: "docker", + executable: runtime, args: ["rm", "-f", containerName], timeoutMs: 5_000, }, diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 3938b7d5c0..6b1c6a9d70 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -1,9 +1,17 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { ServiceDef } from "@supabase/process-compose"; +import { Effect, FileSystem } from "effect"; import { dockerNetworkArgs } from "../Platform.ts"; +import { StackBuildError } from "../errors.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostUserForLinuxDocker, + hostHttpHealthCheck, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; import { stackHealthBudgets } from "./health-budgets.ts"; import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; @@ -18,29 +26,55 @@ interface EdgeRuntimeOptions { readonly dependencies: ReadonlyArray; } -interface NativeEdgeRuntimeOptions extends EdgeRuntimeOptions { - readonly binPath: string; -} - -interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions { +interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions, ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly platformOs: string; + readonly bootstrapDir: string; } const bootstrapFileName = "index.ts"; const bootstrapMountDir = "/workspace"; -const bootstrapSourcePath = new URL("./edge-runtime-main.ts", import.meta.url); -const resolvedBootstrapSource = - bootstrapSource === "" ? readFileSync(bootstrapSourcePath, "utf8") : bootstrapSource; +const bootstrapSourcePath = fileURLToPath(new URL("./edge-runtime-main.ts", import.meta.url)); -function ensureBootstrapScript(runtimeRoot: string): string { - const bootstrapDir = join(runtimeRoot, "edge-runtime"); - mkdirSync(bootstrapDir, { recursive: true }); - const filePath = join(bootstrapDir, bootstrapFileName); - writeFileSync(filePath, resolvedBootstrapSource); - return bootstrapDir; -} +export const prepareEdgeRuntimeBootstrap = ( + runtimeRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bootstrapDir = join(runtimeRoot, "edge-runtime"); + yield* fs.makeDirectory(bootstrapDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to create the Edge Runtime bootstrap directory", + cause, + }), + ), + ); + const source = + bootstrapSource === "" + ? yield* fs.readFileString(bootstrapSourcePath).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to read the Edge Runtime bootstrap script", + cause, + }), + ), + ) + : bootstrapSource; + yield* fs.writeFileString(join(bootstrapDir, bootstrapFileName), source).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to write the Edge Runtime bootstrap script", + cause, + }), + ), + ); + return bootstrapDir; + }); const edgeRuntimeEnv = (opts: EdgeRuntimeOptions): Record => ({ ...opts.env, @@ -68,31 +102,15 @@ const edgeRuntimeHealthCheck = (port: number): ServiceDef["healthCheck"] => ...stackHealthBudgets.edgeRuntime, }); -export const makeEdgeRuntimeServiceNative = (opts: NativeEdgeRuntimeOptions): ServiceDef => { - const bootstrapDir = ensureBootstrapScript(opts.runtimeRoot); - - return { - name: "edge-runtime", - command: `${opts.binPath}/bin/edge-runtime`, - args: [...edgeRuntimeArgs(opts, bootstrapDir)], - env: edgeRuntimeEnv(opts), - dependencies: opts.dependencies, - healthCheck: edgeRuntimeHealthCheck(opts.port), - supervision: {}, - restart: "unless-stopped", - }; -}; - export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): ServiceDef => { - const bootstrapDir = ensureBootstrapScript(opts.runtimeRoot); - return dockerRunService({ + runtime: opts.runtime, name: "edge-runtime", identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [ - `${bootstrapDir}:${bootstrapMountDir}:ro`, + `${opts.bootstrapDir}:${bootstrapMountDir}:ro`, ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), ], args: ["--ulimit", edgeRuntimeNofileUlimit(opts.platformOs).arg], @@ -100,6 +118,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se ...edgeRuntimeEnv(opts), FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, }, + user: hostUserForLinuxDocker(opts.runtime, opts.platformOs), cmd: [...edgeRuntimeArgs(opts, bootstrapMountDir)], dependencies: opts.dependencies, healthCheck: edgeRuntimeHealthCheck(opts.port), diff --git a/packages/stack/src/services/imgproxy.ts b/packages/stack/src/services/imgproxy.ts index 06d1fcd64f..2c9acf0bc7 100644 --- a/packages/stack/src/services/imgproxy.ts +++ b/packages/stack/src/services/imgproxy.ts @@ -1,10 +1,15 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostHttpHealthCheck, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerImgproxyOptions { +interface DockerImgproxyOptions extends ContainerRuntimeOptions { readonly image: string; readonly port: number; readonly identity: StackIdentity; @@ -22,6 +27,7 @@ const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "imgproxy", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index a57d51b60c..193f408e02 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -1,10 +1,15 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostHttpHealthCheck, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerMailpitOptions { +interface DockerMailpitOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly webPort: number; @@ -21,6 +26,7 @@ const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "mailpit", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts index b4a95779c6..fe5d74631b 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerPgmetaOptions { +interface DockerPgmetaOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly port: number; @@ -27,6 +31,7 @@ const pgmetaHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "pgmeta", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts index d638ed2029..10e53633bb 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,12 +1,16 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; type PoolMode = "transaction" | "session"; -interface DockerPoolerOptions { +interface DockerPoolerOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly hostAdminPort: number; @@ -70,6 +74,7 @@ end`; export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef => (() => { return dockerRunService({ + runtime: opts.runtime, name: "pooler", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 63917352b1..cf03582d96 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -1,9 +1,12 @@ import type { ServiceDef } from "@supabase/process-compose"; -import type { ServiceDependency } from "./service-utils.ts"; +import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; +import type { ContainerRuntimeOptions, ServiceDependency } from "./service-utils.ts"; interface PostgresInitOptions { readonly postgresDir: string; readonly dbPort: number; + readonly jwtSecret: string; + readonly jwtExpiry: number; /** * When false, append the SQL that Studio runs at cloud project creation to revoke the default * Data API privileges on the `public` schema so newly-created entities require explicit GRANTs. @@ -12,13 +15,22 @@ interface PostgresInitOptions { readonly dependencies: ReadonlyArray; } +interface DockerPostgresInitOptions extends ContainerRuntimeOptions { + readonly dbPort: number; + readonly jwtSecret: string; + readonly jwtExpiry: number; + readonly autoExposeNewTables: boolean; + readonly identity: StackIdentity; + readonly dependencies: ReadonlyArray; +} + /** * SQL that matches what Studio runs at cloud project creation when "Default privileges for new * entities" is off. Revokes the default GRANTs installed by the bundled initial schema so new * tables/sequences/functions in `public` owned by `postgres` are not reachable via the Data API * roles without explicit GRANTs. */ -export const REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL = ` +const REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL = ` alter default privileges for role postgres in schema public revoke select, insert, update, delete on tables from anon, authenticated, service_role; alter default privileges for role postgres in schema public @@ -27,40 +39,109 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `.trim(); +const dockerPostgresSchemaSql = (opts: DockerPostgresInitOptions) => + ` +\\getenv jwt_secret JWT_SECRET +\\getenv jwt_exp JWT_EXP +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; +ALTER USER postgres WITH PASSWORD 'postgres'; +ALTER USER authenticator WITH PASSWORD 'postgres'; +ALTER USER supabase_auth_admin WITH PASSWORD 'postgres'; +ALTER USER supabase_storage_admin WITH PASSWORD 'postgres'; +ALTER USER supabase_replication_admin WITH PASSWORD 'postgres'; +ALTER USER supabase_read_only_user WITH PASSWORD 'postgres'; +CREATE SCHEMA IF NOT EXISTS _realtime; +ALTER SCHEMA _realtime OWNER TO postgres; +${opts.autoExposeNewTables ? "" : REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} +SELECT 'CREATE DATABASE _supabase WITH OWNER postgres' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')\\gexec +\\connect _supabase +CREATE SCHEMA IF NOT EXISTS _analytics; +ALTER SCHEMA _analytics OWNER TO postgres; +CREATE SCHEMA IF NOT EXISTS _supavisor; +ALTER SCHEMA _supavisor OWNER TO postgres; +`.trim(); + +export const makePostgresInitServiceDocker = (opts: DockerPostgresInitOptions): ServiceDef => ({ + name: "postgres-init", + command: opts.runtime, + args: [ + "exec", + "-e", + "PGPASSWORD", + "-e", + "JWT_SECRET", + "-e", + "JWT_EXP", + dockerContainerName("postgres", opts.identity.key), + "sh", + "-c", + `/opt/postgres/bin/psql -h 127.0.0.1 -p ${opts.dbPort} -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d postgres <<'EOSQL' +${dockerPostgresSchemaSql(opts)} +EOSQL`, + ], + env: { + PGPASSWORD: "postgres", + JWT_SECRET: opts.jwtSecret, + JWT_EXP: String(opts.jwtExpiry), + }, + dependencies: opts.dependencies, + supervision: {}, + restart: "no", +}); + export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { const pgBinDir = `${opts.postgresDir}/bin`; const pgLibDir = `${opts.postgresDir}/lib`; const migrationsDir = `${opts.postgresDir}/share/supabase-cli/migrations`; - const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; - const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc`; - - const revokeStep = opts.autoExposeNewTables - ? "" - : ` - # Revoke default privileges for the Data API roles on schema public so new tables - # require explicit GRANTs. Mirrors Studio's behaviour at cloud project creation. - ${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' -${REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} -EOSQL -`; + // Keep executable and SQL-file paths in arrays so cache roots containing + // whitespace remain single argv entries all the way to psql. + const psqlPath = `${pgBinDir}/psql`; + const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + const psqlArray = `psql=(${shellQuote(psqlPath)} -h 127.0.0.1 -p ${opts.dbPort})`; // Replaces calling migrate.sh (which spawns ~57 separate psql processes) with // chained -f flags that run all SQL files in a single psql session, cutting // postgres-init time from ~5s to ~1s. const script = ` +set -e export PATH="${pgBinDir}:$PATH" -export PGPASSWORD=postgres db="${migrationsDir}" +${psqlArray} +psql_opts=(-v ON_ERROR_STOP=1 --no-password --no-psqlrc) + +init_completion_sql=$(cat <<'EOSQL' +ALTER USER supabase_admin WITH PASSWORD 'postgres'; +CREATE SCHEMA IF NOT EXISTS supabase_migrations; +CREATE TABLE IF NOT EXISTS supabase_migrations.cli_init ( + phase text PRIMARY KEY, + completed_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO supabase_migrations.cli_init (phase) +VALUES ('init') +ON CONFLICT (phase) DO NOTHING; +EOSQL +) + +migration_completion_sql=$(cat <<'EOSQL' +${opts.autoExposeNewTables ? "" : REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} +INSERT INTO supabase_migrations.cli_init (phase) +VALUES ('complete') +ON CONFLICT (phase) DO UPDATE SET completed_at = EXCLUDED.completed_at; +EOSQL +) -# Check if already migrated (authenticator role created by initial-schema.sql) -if ${psql} -U supabase_admin -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='authenticator'" 2>/dev/null | grep -q 1; then - echo "Database already initialized, updating passwords..." +# The init phase is committed independently so a failed migration phase can +# resume without replaying non-idempotent bundled init scripts. +if "\${psql[@]}" -U supabase_admin -d postgres -tAc "SELECT 1 FROM supabase_migrations.cli_init WHERE phase = 'init'" 2>/dev/null | grep -q 1; then + echo "Database initial schema already initialized" else echo "Running Supabase migrations..." # Create postgres role if missing (as supabase_admin) - ${psql} ${psqlOpts} -U supabase_admin -d postgres <<'EOSQL' + "\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres <<'EOSQL' do $$ begin if not exists (select from pg_roles where rolname = 'postgres') then @@ -71,49 +152,56 @@ end $$ EOSQL # Run all init-scripts in a single psql session (as postgres) - init_flags="" + init_flags=() for sql in "$db"/init-scripts/*.sql; do - [ -f "$sql" ] && init_flags="$init_flags -f $sql" + [ -f "$sql" ] && init_flags+=( -f "$sql" ) done - if [ -n "$init_flags" ]; then - ${psql} ${psqlOpts} -U postgres -d postgres $init_flags - fi + "\${psql[@]}" "\${psql_opts[@]}" --single-transaction -U postgres -d postgres "\${init_flags[@]}" -c "$init_completion_sql" +fi - # Set supabase_admin password (as postgres) - ${psql} ${psqlOpts} -U postgres -d postgres -c "ALTER USER supabase_admin WITH PASSWORD 'postgres'" +if "\${psql[@]}" -U supabase_admin -d postgres -tAc "SELECT 1 FROM supabase_migrations.cli_init WHERE phase = 'complete'" 2>/dev/null | grep -q 1; then + echo "Database migrations already initialized" +else + echo "Running Supabase migrations..." # Run all migrations in a single psql session (as supabase_admin) - migrate_flags="" + migrate_flags=() for sql in "$db"/migrations/*.sql; do - [ -f "$sql" ] && migrate_flags="$migrate_flags -f $sql" + [ -f "$sql" ] && migrate_flags+=( -f "$sql" ) done - if [ -n "$migrate_flags" ]; then - ${psql} ${psqlOpts} -U supabase_admin -d postgres $migrate_flags - fi + "\${psql[@]}" "\${psql_opts[@]}" --single-transaction -U supabase_admin -d postgres "\${migrate_flags[@]}" -c "$migration_completion_sql" # Reset stats (non-fatal, matches migrate.sh) - ${psql} ${psqlOpts} -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true -${revokeStep}fi + "\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true +fi # Backfill schemas/databases used by docker-backed auxiliary services. -${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' +"\${psql[@]}" "\${psql_opts[@]}" -U postgres -d postgres <<'EOSQL' CREATE SCHEMA IF NOT EXISTS _realtime; ALTER SCHEMA _realtime OWNER TO postgres; EOSQL -if ! ${psql} -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '_supabase'" 2>/dev/null | grep -q 1; then - ${psql} ${psqlOpts} -U postgres -d postgres -c "CREATE DATABASE _supabase WITH OWNER postgres" +if ! "\${psql[@]}" -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '_supabase'" 2>/dev/null | grep -q 1; then + "\${psql[@]}" "\${psql_opts[@]}" -U postgres -d postgres -c "CREATE DATABASE _supabase WITH OWNER postgres" fi -${psql} ${psqlOpts} -U postgres -d _supabase <<'EOSQL' +"\${psql[@]}" "\${psql_opts[@]}" -U postgres -d _supabase <<'EOSQL' CREATE SCHEMA IF NOT EXISTS _analytics; ALTER SCHEMA _analytics OWNER TO postgres; CREATE SCHEMA IF NOT EXISTS _supavisor; ALTER SCHEMA _supavisor OWNER TO postgres; EOSQL +# Always update JWT settings after the bundled roles and schemas exist. +"\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres <<'EOSQL' +\\getenv jwt_secret JWT_SECRET +\\getenv jwt_exp JWT_EXP +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; +EOSQL + # Always update role passwords (idempotent) -${psql} -U supabase_admin -d postgres -c " +"\${psql[@]}" -U supabase_admin -d postgres -c " DO \\$\\$ DECLARE roles text[] := ARRAY['authenticator','supabase_auth_admin','supabase_storage_admin','supabase_functions_admin','supabase_replication_admin','supabase_read_only_user','postgres']; @@ -137,6 +225,8 @@ END DYLD_LIBRARY_PATH: pgLibDir, LD_LIBRARY_PATH: pgLibDir, PGPASSWORD: "postgres", + JWT_SECRET: opts.jwtSecret, + JWT_EXP: String(opts.jwtExpiry), }, dependencies: opts.dependencies, supervision: {}, diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index 910b76d544..a3c99216f9 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -1,4 +1,3 @@ -import { mkdirSync, writeFileSync } from "node:fs"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; @@ -7,6 +6,8 @@ import { stackHealthBudgets } from "./health-budgets.ts"; import { dockerExecHealthCheck, dockerRunService, + hostUserForLinuxDocker, + type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; @@ -19,15 +20,11 @@ interface PostgresServiceOptions { interface NativePostgresOptions extends PostgresServiceOptions { readonly binPath: string; - /** When true, patches postgres to listen on all interfaces so Docker containers can connect. */ - readonly dockerAccessible?: boolean; } -interface DockerPostgresOptions extends PostgresServiceOptions { +interface DockerPostgresOptions extends PostgresServiceOptions, ContainerRuntimeOptions { readonly image: string; readonly platformOs: string; - readonly jwtSecret: string; - readonly jwtExpiry: number; readonly identity: StackIdentity; readonly cleanupDataDirOnExit?: boolean; } @@ -40,13 +37,9 @@ const postgresEnv = (opts: NativePostgresOptions): Record => ({ TZDIR: "/var/db/timezone/zoneinfo", }); -const postgresDockerEnv = (opts: DockerPostgresOptions): Record => ({ - POSTGRES_PASSWORD: "postgres", - JWT_SECRET: opts.jwtSecret, - JWT_EXP: String(opts.jwtExpiry), -}); - const NATIVE_POSTGRES_RUNTIME_ARGS = [ + "-c", + "listen_addresses=127.0.0.1", "-c", "wal_level=logical", "-c", @@ -58,31 +51,8 @@ const NATIVE_POSTGRES_RUNTIME_ARGS = [ const orphanCleanup = (opts: PostgresServiceOptions) => opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir) : []; -const DOCKER_POSTGRES_SCHEMA_SQL = `\\set pgpass \`echo "$PGPASSWORD"\` -\\set jwt_secret \`echo "$JWT_SECRET"\` -\\set jwt_exp \`echo "$JWT_EXP"\` -ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; -ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; -ALTER USER postgres WITH PASSWORD :'pgpass'; -ALTER USER authenticator WITH PASSWORD :'pgpass'; -ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_replication_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_read_only_user WITH PASSWORD :'pgpass'; -create schema if not exists _realtime; -alter schema _realtime owner to postgres; -SELECT 'CREATE DATABASE _supabase WITH OWNER postgres' -WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')\\gexec -\\connect _supabase -create schema if not exists _analytics; -alter schema _analytics owner to postgres; -create schema if not exists _supavisor; -alter schema _supavisor owner to postgres;`; - -const dockerPostgresEntrypoint = (port: number) => - `cat <<'EOF' > /etc/postgresql.schema.sql && exec docker-entrypoint.sh postgres -D /etc/postgresql -p ${port} -${DOCKER_POSTGRES_SCHEMA_SQL} -EOF`; +const postgresGetKeyScript = (binPath: string): string => + `${binPath}/share/supabase-cli/config/pgsodium_getkey.sh`; const postgresHealthCheck = (binPath: string, port: number) => ({ probe: { @@ -98,71 +68,54 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ }); /** - * Docker postgres health check using pg_isready inside the container. + * Docker postgres health check using the final postgres process and pg_isready + * inside the container. * - * TCP alone is insufficient because the supabase/postgres image accepts TCP - * connections during its init phase (running init scripts) but drops real - * queries with "unexpected EOF". We use `docker exec` to run pg_isready - * inside the container, which verifies postgres is accepting commands. + * The supabase/postgres image briefly accepts connections while its entrypoint + * runs initialization. During that phase PID 1 is still the shell and the + * temporary server is stopped before the final postgres process starts. Gate + * readiness on the final Postgres process name and pg_isready so dependents + * never race that handoff. `/proc/1/exe` is intentionally avoided because + * Linux container hardening can make that symlink unreadable across users. */ -const postgresDockerHealthCheck = (containerName: string, port: number) => - dockerExecHealthCheck(containerName, "pg_isready", ["-p", String(port), "-U", "postgres"], { - ...stackHealthBudgets.postgresDocker, - }); +const postgresDockerHealthCheck = ( + runtime: DockerPostgresOptions["runtime"], + containerName: string, + port: number, +) => + dockerExecHealthCheck( + runtime, + containerName, + "sh", + [ + "-ec", + // Linux /proc/1/comm truncates `.postgres-wrapped` to 15 characters. + `case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -h 127.0.0.1 -p ${port} -U postgres ;; *) exit 1 ;; esac`, + ], + { + ...stackHealthBudgets.postgresDocker, + }, + ); export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { + // The bundle path is a private, scope-owned alias prepared by StackBuilder. + // It intentionally does not persist between handles: the initializer only + // needs a no-space path while this service definition is active. const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; - - if (opts.dockerAccessible) { - // Docker containers connect via host.docker.internal, which resolves to a gateway IP - // rather than 127.0.0.1. We create a per-run pg_hba.conf that allows those - // connections, and use postgres -c flags to override listen_addresses and hba_file. - // This avoids mutating the shared binary cache. - const customHbaPath = `${opts.dataDir}_pg_hba_docker.conf`; - mkdirSync(opts.dataDir, { recursive: true }); - writeFileSync( - customHbaPath, - [ - "local all all scram-sha-256", - "host all all 127.0.0.1/32 scram-sha-256", - "host all all ::1/128 scram-sha-256", - "host all all 0.0.0.0/0 scram-sha-256", - "", - ].join("\n"), - "utf8", - ); - - return { - name: "postgres", - command: "bash", - args: [ - initScript, - "-p", - String(opts.port), - ...NATIVE_POSTGRES_RUNTIME_ARGS, - "-c", - "listen_addresses=*", - "-c", - `hba_file=${customHbaPath}`, - ], - env: postgresEnv(opts), - dependencies: opts.dependencies, - healthCheck: postgresHealthCheck(opts.binPath, opts.port), - shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - supervision: { - orphanCleanup: [ - ...orphanCleanup(opts), - ...removePathOnOrphanCleanup(customHbaPath, { recursive: false }), - ], - }, - restart: "unless-stopped", - }; - } + const getKeyScript = postgresGetKeyScript(opts.binPath); return { name: "postgres", - command: "bash", - args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], + command: initScript, + args: [ + "-p", + String(opts.port), + ...NATIVE_POSTGRES_RUNTIME_ARGS, + "-c", + `pgsodium.getkey_script=${getKeyScript}`, + "-c", + `vault.getkey_script=${getKeyScript}`, + ], env: postgresEnv(opts), dependencies: opts.dependencies, healthCheck: postgresHealthCheck(opts.binPath, opts.port), @@ -173,19 +126,59 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => }; export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { - const env = postgresDockerEnv(opts); const containerName = dockerContainerName("postgres", opts.identity.key); + const hostUser = hostUserForLinuxDocker(opts.runtime, opts.platformOs); + const [hostUid, hostGid] = hostUser?.split(":") ?? []; + const runtimeArgs = [ + "-p", + String(opts.port), + "-c", + "listen_addresses=*", + "-c", + "pgsodium.getkey_script=/opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh", + "-c", + "vault.getkey_script=/opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh", + ] as const; + + // Native initialization permits only loopback clients. When reusing that + // data directory in Docker, route through a temporary HBA copy that adds the + // container network rule without mutating the persisted native config. + const runEntrypoint = (args: string): string => + hostUser === undefined + ? `exec /usr/local/bin/entry.sh ${args}` + : `exec busybox su -s /usr/bin/sh supabase_cli -c "exec /usr/local/bin/entry.sh ${args}"`; + // initdb requires the effective uid to resolve through /etc/passwd, while + // the image init script chmods its key helper. Perform only that image setup + // as root, then drop to the host uid before touching the bind-mounted data. + const hostUserSetup = + hostUser === undefined + ? "" + : `printf 'supabase_cli:x:${hostUid}:${hostGid}:Supabase CLI:/tmp:/usr/bin/sh\\n' >> /etc/passwd +busybox chown ${hostUid}:${hostGid} /var/lib/postgresql/data +busybox chown ${hostUid}:${hostGid} /opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh +`; + const command = `${hostUserSetup}if [ -s /var/lib/postgresql/data/PG_VERSION ]; then + cp /var/lib/postgresql/data/pg_hba.conf /tmp/supabase-cli-pg_hba.conf + printf '\\nhost all all all scram-sha-256\\n' >> /tmp/supabase-cli-pg_hba.conf + ${hostUser === undefined ? "" : `busybox chown ${hostUid}:${hostGid} /tmp/supabase-cli-pg_hba.conf`} + ${runEntrypoint(`-c hba_file=/tmp/supabase-cli-pg_hba.conf ${runtimeArgs.join(" ")}`)} +else + ${runEntrypoint(runtimeArgs.join(" "))} +fi`; + return dockerRunService({ + runtime: opts.runtime, name: "postgres", identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:/var/lib/postgresql/data`], - env, - entrypoint: "sh", - cmd: ["-c", dockerPostgresEntrypoint(opts.port)], + env: { POSTGRES_PASSWORD: "postgres" }, + user: hostUser === undefined ? undefined : "0", + entrypoint: "/usr/bin/sh", + cmd: ["-c", command], dependencies: opts.dependencies, - healthCheck: postgresDockerHealthCheck(containerName, opts.port), + healthCheck: postgresDockerHealthCheck(opts.runtime, containerName, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, orphanCleanup: orphanCleanup(opts), }); diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts index e9aea4ce05..bfb211b8c1 100644 --- a/packages/stack/src/services/postgrest.ts +++ b/packages/stack/src/services/postgrest.ts @@ -2,7 +2,11 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; interface PostgrestServiceOptions { readonly dbPort: number; @@ -18,7 +22,7 @@ interface NativePostgrestOptions extends PostgrestServiceOptions { readonly binPath: string; } -interface DockerPostgrestOptions extends PostgrestServiceOptions { +interface DockerPostgrestOptions extends PostgrestServiceOptions, ContainerRuntimeOptions { readonly image: string; readonly dbHost: string; readonly platformOs: string; @@ -52,7 +56,7 @@ const postgrestHealthCheck = (port: number) => ({ export const makePostgrestService = (opts: NativePostgrestOptions): ServiceDef => ({ name: "postgrest", - command: `${opts.binPath}/postgrest`, + command: `${opts.binPath}/bin/postgrest`, env: postgrestEnv(opts), dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), @@ -66,6 +70,7 @@ export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): Servic PGRST_ADMIN_SERVER_PORT: String(opts.adminPort), }; return dockerRunService({ + runtime: opts.runtime, name: "postgrest", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index 32ac4d53a0..0783ed830f 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerRealtimeOptions { +interface DockerRealtimeOptions extends ContainerRuntimeOptions { readonly image: string; readonly port: number; readonly identity: StackIdentity; @@ -39,6 +43,7 @@ const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["health export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "realtime", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts index 230741cfde..f26ddd9390 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -1,5 +1,6 @@ import type { ExternalCleanupAction, ServiceDef } from "@supabase/process-compose"; import type { ServiceName } from "../ServiceName.ts"; +import type { ContainerRuntime } from "../ContainerRuntime.ts"; import { dockerContainerName, STACK_ID_LABEL, type StackIdentity } from "../StackIdentity.ts"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; @@ -8,7 +9,11 @@ export interface ServiceDependency { readonly condition: "healthy" | "completed"; } -interface DockerRunServiceOptions { +export interface ContainerRuntimeOptions { + readonly runtime: ContainerRuntime; +} + +interface DockerRunServiceOptions extends ContainerRuntimeOptions { readonly name: ServiceName; readonly identity: StackIdentity; readonly image: string; @@ -18,6 +23,8 @@ interface DockerRunServiceOptions { readonly cmd?: ReadonlyArray; readonly entrypoint?: string; readonly volumes?: ReadonlyArray; + readonly securityOptions?: ReadonlyArray; + readonly user?: string; readonly dependencies: ReadonlyArray; readonly healthCheck?: ServiceDef["healthCheck"]; readonly restart?: ServiceDef["restart"]; @@ -25,8 +32,20 @@ interface DockerRunServiceOptions { readonly orphanCleanup?: ReadonlyArray; } +export const hostUserForLinuxDocker = ( + runtime: ContainerRuntime, + platformOs: string, +): string | undefined => { + // Linux bind mounts preserve numeric ownership. Matching the caller keeps + // private runtime files readable and persistent data removable by the host. + if (runtime !== "docker" || platformOs !== "linux") return undefined; + const uid = process.getuid?.(); + const gid = process.getgid?.(); + return uid === undefined || gid === undefined ? undefined : `${uid}:${gid}`; +}; + const envArgs = (env: Record): ReadonlyArray => - Object.entries(env).flatMap(([key, value]) => ["-e", `${key}=${value}`]); + Object.keys(env).flatMap((key) => ["-e", key]); export const hostHttpHealthCheck = ( port: number, @@ -44,6 +63,7 @@ export const hostHttpHealthCheck = ( }); export const dockerExecHealthCheck = ( + runtime: ContainerRuntime, containerName: string, command: string, args: ReadonlyArray, @@ -51,7 +71,7 @@ export const dockerExecHealthCheck = ( ): ServiceDef["healthCheck"] => ({ probe: { _tag: "Exec", - command: "docker", + command: runtime, args: ["exec", containerName, command, ...args], }, ...opts, @@ -71,6 +91,8 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { : ["--label", `${STACK_ID_LABEL}=${opts.identity.stackId}`]), ...(opts.networkArgs ?? []), ...(opts.volumes ?? []).flatMap((volume) => ["-v", volume]), + ...(opts.securityOptions ?? []).flatMap((option) => ["--security-opt", option]), + ...(opts.user === undefined ? [] : ["--user", opts.user]), ...(opts.entrypoint === undefined ? [] : ["--entrypoint", opts.entrypoint]), ...(opts.args ?? []), ...envArgs(opts.env ?? {}), @@ -80,14 +102,18 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { return { name: opts.name, - command: "docker", + command: opts.runtime, args: dockerArgs, + env: opts.env, dependencies: opts.dependencies, healthCheck: opts.healthCheck, shutdown: opts.shutdown, - cleanup: dockerServiceCleanup(containerName), + cleanup: dockerServiceCleanup(opts.runtime, containerName), supervision: { - orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...(opts.orphanCleanup ?? [])], + orphanCleanup: [ + ...dockerServiceOrphanCleanup(opts.runtime, containerName), + ...(opts.orphanCleanup ?? []), + ], }, restart: opts.restart ?? "unless-stopped", }; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index ca84c3b279..d12004f3aa 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,22 +1,21 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { Predicate } from "effect"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; -import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; +import { makeEdgeRuntimeServiceDocker } from "./edge-runtime.ts"; import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; import { makePgmetaServiceDocker } from "./pgmeta.ts"; -import { - makePostgresInitService, - REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL, -} from "./postgres-init.ts"; +import { makePostgresInitService, makePostgresInitServiceDocker } from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./postgrest.ts"; import { makeRealtimeServiceDocker } from "./realtime.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; +import { dockerRunService } from "./service-utils.ts"; import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, @@ -36,7 +35,36 @@ const EPHEMERAL_IDENTITY: StackIdentity = stackIdentity({ apiPort: API_PORT }); const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-arm64`; const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; const AUTH_BIN_PATH = `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`; -const EDGE_RUNTIME_BIN_PATH = `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`; + +describe("dockerRunService environment transport", () => { + it("keeps secret values in the child environment instead of argv", () => { + const env = { + PASSWORD: "postgres-password", + JWT_SECRET, + }; + const def = dockerRunService({ + runtime: "docker", + name: "auth", + identity: EPHEMERAL_IDENTITY, + image: "supabase/auth:test", + env, + dependencies: [], + }); + + expect(def.env).toEqual(env); + const args = def.args ?? []; + for (const [key, value] of Object.entries(env)) { + const index = args.indexOf(key); + expect(index).toBeGreaterThanOrEqual(0); + expect(args[index - 1]).toBe("-e"); + expect(args[index + 1]).not.toBe(value); + } + expect(args.every((arg) => !Object.values(env).some((value) => arg.includes(value)))).toBe( + true, + ); + }); +}); + describe("makePostgresService", () => { it("creates a postgres ServiceDef with correct defaults", () => { const def = makePostgresService({ @@ -47,18 +75,10 @@ describe("makePostgresService", () => { }); expect(def.name).toBe("postgres"); - expect(def.command).toBe("bash"); - expect(def.args).toEqual([ + expect(def.command).toBe( `${POSTGRES_BIN_PATH}/share/supabase-cli/bin/supabase-postgres-init.sh`, - "-p", - "54322", - "-c", - "wal_level=logical", - "-c", - "max_wal_senders=5", - "-c", - "max_replication_slots=5", - ]); + ); + expect(def.args).toContain("listen_addresses=127.0.0.1"); expect(def.env?.PGDATA).toBe("/tmp/supabase/data"); expect(def.env?.POSTGRES_PASSWORD).toBe("postgres"); expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); @@ -96,6 +116,7 @@ describe("analyticsDockerRuntimeNetwork", () => { describe("makeStudioServiceDocker", () => { it("injects legacy keys, opaque keys, and S3 protocol credentials", () => { const def = makeStudioServiceDocker({ + runtime: "docker", image: dockerImageForService("studio", DEFAULT_VERSIONS.studio), identity: EPHEMERAL_IDENTITY, port: 54323, @@ -115,71 +136,26 @@ describe("makeStudioServiceDocker", () => { dependencies: [{ service: "pgmeta", condition: "healthy" }], }); - expect(def.args).toContain("SUPABASE_ANON_KEY=sb_publishable_test"); - expect(def.args).toContain("SUPABASE_SERVICE_KEY=sb_secret_test"); - expect(def.args).toContain("SUPABASE_PUBLISHABLE_KEY=sb_publishable_test"); - expect(def.args).toContain("SUPABASE_SECRET_KEY=sb_secret_test"); - expect(def.args).toContain(`S3_PROTOCOL_ACCESS_KEY_ID=${LOCAL_S3_PROTOCOL_ACCESS_KEY_ID}`); - expect(def.args).toContain( - `S3_PROTOCOL_ACCESS_KEY_SECRET=${LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET}`, - ); - }); -}); - -describe("makePostgresService (dockerAccessible)", () => { - it("creates per-run pg_hba.conf instead of mutating shared cache", () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "stack-postgres-service-")); - const def = makePostgresService({ - binPath: POSTGRES_BIN_PATH, - dataDir: path.join(tempDir, "data"), - port: DB_PORT, - dockerAccessible: true, - cleanupDataDirOnExit: true, - dependencies: [], + expect(def.env).toMatchObject({ + SUPABASE_ANON_KEY: "sb_publishable_test", + SUPABASE_SERVICE_KEY: "sb_secret_test", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_SECRET_KEY: "sb_secret_test", + S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, }); - const customHbaPath = `${path.join(tempDir, "data")}_pg_hba_docker.conf`; - - try { - expect(def.name).toBe("postgres"); - expect(def.command).toBe("bash"); - expect(def.args).toEqual([ - `${POSTGRES_BIN_PATH}/share/supabase-cli/bin/supabase-postgres-init.sh`, - "-p", - "54322", - "-c", - "wal_level=logical", - "-c", - "max_wal_senders=5", - "-c", - "max_replication_slots=5", - "-c", - "listen_addresses=*", - "-c", - `hba_file=${customHbaPath}`, - ]); - expect(readFileSync(customHbaPath, "utf8")).toContain("0.0.0.0/0"); - expect(def.supervision).toEqual({ - orphanCleanup: [ - { _tag: "RemovePath", path: path.join(tempDir, "data") }, - { _tag: "RemovePath", path: customHbaPath, recursive: false }, - ], - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - rmSync(customHbaPath, { force: true }); - } + expect(def.args).not.toContain("sb_secret_test"); }); }); describe("makePostgresServiceDocker", () => { it("creates a docker-based postgres ServiceDef", () => { const def = makePostgresServiceDocker({ + runtime: "docker", image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, platformOs: "linux", - jwtSecret: "test-jwt-secret-with-at-least-32-characters", - jwtExpiry: 3600, identity: EPHEMERAL_IDENTITY, dependencies: [], }); @@ -193,56 +169,19 @@ describe("makePostgresServiceDocker", () => { expect(def.args).toContain(`${DB_PORT}:${DB_PORT}`); expect(def.args).toContain(dockerImageForService("postgres", DEFAULT_VERSIONS.postgres)); expect(def.args).toContain("/tmp/supabase/data:/var/lib/postgresql/data"); - // Verify port is passed to postgres inside the container - expect(def.args?.[def.args.length - 1]).toContain(`-p ${DB_PORT}`); - // Health check uses docker exec + pg_isready inside the container (host has no postgres tools) - expect(def.healthCheck?.probe).toEqual({ - _tag: "Exec", - command: "docker", - args: [ - "exec", - `supabase-postgres-${API_PORT}`, - "pg_isready", - "-p", - "54322", - "-U", - "postgres", - ], - }); + expect(def.args).toContain("/usr/bin/sh"); + expect(def.args?.at(-2)).toBe("-c"); + // The Linux-compatible health gate distinguishes the final server from + // the image's temporary initialization server. + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Exec", command: "docker" }), + ); + expect( + Predicate.isTagged(def.healthCheck?.probe, "Exec") && def.healthCheck.probe.args.join(" "), + ).toContain("/proc/1/comm"); expect(def.dependencies).toEqual([]); expect(def.restart).toBe("unless-stopped"); - expect(def.supervision).toEqual({ - orphanCleanup: [ - { - _tag: "RunCommand", - executable: "docker", - args: ["rm", "-f", `supabase-postgres-${API_PORT}`], - timeoutMs: 5_000, - }, - ], - }); - }); - - it("bootstraps auxiliary databases and schemas used by docker-backed services", () => { - const def = makePostgresServiceDocker({ - image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), - dataDir: "/tmp/supabase/data", - port: DB_PORT, - platformOs: "linux", - jwtSecret: "test-jwt-secret-with-at-least-32-characters", - jwtExpiry: 3600, - identity: EPHEMERAL_IDENTITY, - dependencies: [], - }); - - const script = def.args?.[def.args.length - 1] as string; - expect(script).toContain("CREATE DATABASE _supabase WITH OWNER postgres"); - expect(script).toContain( - "WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')", - ); - expect(script).toContain("\\connect _supabase"); - expect(script).toContain("create schema if not exists _analytics;"); - expect(script).toContain("create schema if not exists _supavisor;"); + expect(def.supervision?.orphanCleanup).toBeDefined(); }); }); @@ -260,7 +199,7 @@ describe("makePostgrestService", () => { }); expect(def.name).toBe("postgrest"); - expect(def.command).toBe(`${POSTGREST_BIN_PATH}/postgrest`); + expect(def.command).toBe(`${POSTGREST_BIN_PATH}/bin/postgrest`); expect(def.env?.PGRST_DB_URI).toBe( `postgresql://authenticator:postgres@127.0.0.1:${DB_PORT}/postgres`, ); @@ -281,6 +220,7 @@ describe("makePostgrestService", () => { it("creates a docker definition with caller-supplied topology and derived identity", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; const def = makePostgrestServiceDocker({ + runtime: "docker", image: dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest), identity: EPHEMERAL_IDENTITY, dbHost: "host.docker.internal", @@ -300,7 +240,7 @@ describe("makePostgrestService", () => { expect(def.args).toContain("host.docker.internal:host-gateway"); expect(def.args).toContain("54323:54323"); expect(def.args).toContain("54324:54324"); - expect(def.args).toContain("PGRST_ADMIN_SERVER_PORT=54324"); + expect(def.env?.PGRST_ADMIN_SERVER_PORT).toBe("54324"); expect(def.dependencies).toEqual(dependencies); expect(def.supervision?.orphanCleanup).toContainEqual({ _tag: "RunCommand", @@ -325,7 +265,7 @@ describe("makeAuthServiceNative", () => { }); expect(def.name).toBe("auth"); - expect(def.command).toBe(`${AUTH_BIN_PATH}/auth`); + expect(def.command).toBe(`${AUTH_BIN_PATH}/bin/auth`); expect(def.env?.GOTRUE_DB_DATABASE_URL).toContain(`127.0.0.1:${DB_PORT}`); expect(def.env?.GOTRUE_SITE_URL).toBe("http://localhost:3000"); expect(def.env?.GOTRUE_JWT_SECRET).toBe(JWT_SECRET); @@ -344,6 +284,7 @@ describe("makeAuthServiceNative", () => { describe("makeAuthServiceDocker", () => { it("creates a docker-based auth ServiceDef", () => { const def = makeAuthServiceDocker({ + runtime: "docker", image: dockerImageForService("auth", DEFAULT_VERSIONS.auth), dbPort: DB_PORT, authPort: 9999, @@ -384,9 +325,11 @@ describe("makeEdgeRuntimeServiceDocker", () => { try { const def = makeEdgeRuntimeServiceDocker({ + runtime: "docker", image: dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"]), identity: EPHEMERAL_IDENTITY, runtimeRoot: tempDir, + bootstrapDir: path.join(tempDir, "edge-runtime"), port: 54340, inspectorPort: 54341, policy: "per_worker", @@ -396,9 +339,6 @@ describe("makeEdgeRuntimeServiceDocker", () => { }); const bootstrapDir = path.join(tempDir, "edge-runtime"); - const bootstrapPath = path.join(bootstrapDir, "index.ts"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("FUNCTIONS_NOT_CONFIGURED"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("/_internal/health"); expect(def.name).toBe("edge-runtime"); expect(def.command).toBe("docker"); expect(def.args).toContain(`supabase-edge-runtime-${API_PORT}`); @@ -423,51 +363,13 @@ describe("makeEdgeRuntimeServiceDocker", () => { }); }); -describe("makeEdgeRuntimeServiceNative", () => { - it("creates a native edge runtime service with a generated bootstrap script", () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "stack-edge-runtime-native-")); - - try { - const def = makeEdgeRuntimeServiceNative({ - binPath: EDGE_RUNTIME_BIN_PATH, - runtimeRoot: tempDir, - port: 54340, - inspectorPort: 54341, - policy: "per_worker", - env: { SUPABASE_INTERNAL_DEBUG: "true" }, - dependencies: [{ service: "postgres-init", condition: "completed" }], - }); - - const bootstrapPath = path.join(tempDir, "edge-runtime", "index.ts"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("FUNCTIONS_NOT_CONFIGURED"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("/_internal/health"); - expect(def.name).toBe("edge-runtime"); - expect(def.command).toBe(`${EDGE_RUNTIME_BIN_PATH}/bin/edge-runtime`); - expect(def.args).toContain("start"); - expect(def.args).toContain(`--main-service=${path.join(tempDir, "edge-runtime")}`); - expect(def.args).toContain(`--port=54340`); - expect(def.args).toContain(`--policy=per_worker`); - expect(def.env?.EDGE_RUNTIME_INSPECTOR_PORT).toBe("54341"); - expect(def.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54340, - path: "/_internal/health", - scheme: "http", - }); - expect(def.supervision).toEqual({}); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); -}); - describe("makePostgresInitService", () => { it("creates a one-shot postgres-init ServiceDef", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + jwtSecret: JWT_SECRET, + jwtExpiry: 3600, autoExposeNewTables: true, dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -480,97 +382,50 @@ describe("makePostgresInitService", () => { expect(def.healthCheck).toBeUndefined(); expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); expect(def.env?.LD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); + expect(def.env?.JWT_SECRET).toBe(JWT_SECRET); + expect(def.env?.JWT_EXP).toBe("3600"); expect(def.supervision).toBeDefined(); }); - it("does not use set -e (matches Go template approach)", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).not.toContain("set -e"); - }); - - it("includes idempotency check for authenticator role", () => { - const def = makePostgresInitService({ - postgresDir: "/cache/postgres/17/darwin-arm64", - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).toContain("authenticator"); - expect(script).toContain("already initialized"); - }); - - it("backfills auxiliary service schemas and internal databases", () => { - const def = makePostgresInitService({ - postgresDir: "/cache/postgres/17/darwin-arm64", - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - - expect(script).toContain("CREATE SCHEMA IF NOT EXISTS _realtime"); - expect(script).toContain("SELECT 1 FROM pg_database WHERE datname = '_supabase'"); - expect(script).toContain("CREATE DATABASE _supabase WITH OWNER postgres"); - expect(script).toContain("CREATE SCHEMA IF NOT EXISTS _analytics"); - expect(script).toContain("CREATE SCHEMA IF NOT EXISTS _supavisor"); - }); - - it("batches SQL files via chained -f flags instead of shelling out to migrate.sh", () => { - const def = makePostgresInitService({ - postgresDir: "/cache/postgres/17/darwin-arm64", - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).not.toMatch(/sh .+migrate\.sh/); - expect(script).toContain("-f $sql"); - expect(script).toContain("init-scripts/*.sql"); - expect(script).toContain("migrations/*.sql"); - }); - - it("does not revoke default Data API privileges when autoExposeNewTables is true", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).not.toContain("alter default privileges"); - expect(script).not.toContain("revoke select, insert, update, delete on tables"); - }); - - it("revokes default Data API privileges on `public` when autoExposeNewTables is false", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - autoExposeNewTables: false, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).toContain(REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL); - expect(script).toContain( - "revoke select, insert, update, delete on tables from anon, authenticated, service_role", - ); - expect(script).toContain( - "revoke usage, select on sequences from anon, authenticated, service_role", - ); - expect(script).toContain("revoke execute on functions from anon, authenticated, service_role"); - }); + it.each(["native", "docker"] as const)( + "%s initialization revokes default Data API privileges only when auto-exposure is disabled", + (mode) => { + const commandFor = (autoExposeNewTables: boolean): string => { + const common = { + dbPort: DB_PORT, + jwtSecret: JWT_SECRET, + jwtExpiry: 3600, + autoExposeNewTables, + dependencies: [{ service: "postgres", condition: "healthy" }] as const, + }; + const definition = + mode === "native" + ? makePostgresInitService({ ...common, postgresDir: POSTGRES_BIN_PATH }) + : makePostgresInitServiceDocker({ + ...common, + runtime: "docker", + jwtSecret: JWT_SECRET, + jwtExpiry: 3600, + identity: EPHEMERAL_IDENTITY, + }); + return definition.args?.join("\n") ?? ""; + }; + + expect(commandFor(true)).not.toContain( + "alter default privileges for role postgres in schema public", + ); + expect(commandFor(false)).toContain( + "alter default privileges for role postgres in schema public", + ); + }, + ); }); describe("docker-backed auxiliary services", () => { it("defines realtime command, topology, environment, and readiness locally", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; const def = makeRealtimeServiceDocker({ + runtime: "docker", image: dockerImageForService("realtime", DEFAULT_VERSIONS.realtime), identity: EPHEMERAL_IDENTITY, port: 54330, @@ -588,7 +443,7 @@ describe("docker-backed auxiliary services", () => { expect(def.args).toContain(`supabase-realtime-${API_PORT}`); expect(def.args).toContain("54330:54330"); - expect(def.args).toContain("DB_HOST=host.docker.internal"); + expect(def.env?.DB_HOST).toBe("host.docker.internal"); expect(def.dependencies).toEqual(dependencies); expect(def.healthCheck?.probe).toEqual( expect.objectContaining({ _tag: "Exec", command: "curl" }), @@ -598,6 +453,7 @@ describe("docker-backed auxiliary services", () => { it("defines storage mounts, cleanup, topology, and readiness locally", () => { const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; const def = makeStorageServiceDocker({ + runtime: "docker", image: dockerImageForService("storage", DEFAULT_VERSIONS.storage), identity: EPHEMERAL_IDENTITY, port: 54331, @@ -634,6 +490,7 @@ describe("docker-backed auxiliary services", () => { it("defines postgres metadata command, topology, environment, and readiness locally", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; const def = makePgmetaServiceDocker({ + runtime: "docker", image: dockerImageForService("pgmeta", DEFAULT_VERSIONS.pgmeta), identity: EPHEMERAL_IDENTITY, port: 54336, @@ -645,7 +502,7 @@ describe("docker-backed auxiliary services", () => { expect(def.args).toContain(`supabase-pgmeta-${API_PORT}`); expect(def.args).toContain("54336:54336"); - expect(def.args).toContain("PG_META_DB_HOST=host.docker.internal"); + expect(def.env?.PG_META_DB_HOST).toBe("host.docker.internal"); expect(def.dependencies).toEqual(dependencies); expect(def.healthCheck?.probe).toEqual( expect.objectContaining({ _tag: "Http", port: 54336, path: "/health" }), @@ -654,6 +511,7 @@ describe("docker-backed auxiliary services", () => { it("uses a host HTTP readiness probe for mailpit", () => { const def = makeMailpitServiceDocker({ + runtime: "docker", image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), identity: EPHEMERAL_IDENTITY, webPort: 54323, @@ -674,6 +532,7 @@ describe("docker-backed auxiliary services", () => { it("uses a host HTTP health probe for imgproxy", () => { const def = makeImgproxyServiceDocker({ + runtime: "docker", image: dockerImageForService("imgproxy", DEFAULT_VERSIONS.imgproxy), identity: EPHEMERAL_IDENTITY, port: 54326, @@ -694,6 +553,7 @@ describe("docker-backed auxiliary services", () => { it("uses docker exec for vector health because its admin port is not published", () => { const def = makeVectorServiceDocker({ + runtime: "docker", image: dockerImageForService("vector", DEFAULT_VERSIONS.vector), identity: EPHEMERAL_IDENTITY, serviceHost: "127.0.0.1", @@ -718,6 +578,7 @@ describe("docker-backed auxiliary services", () => { it("binds analytics on all interfaces so published ports and proxy health checks work", () => { const def = makeAnalyticsServiceDocker({ + runtime: "docker", image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), identity: EPHEMERAL_IDENTITY, hostPort: 54328, @@ -738,17 +599,15 @@ describe("docker-backed auxiliary services", () => { scheme: "http", }); expect(def.healthCheck?.initialDelaySeconds).toBe(10); - expect(args).toContain("PORT=4000"); - expect(args).toContain("PHX_HTTP_PORT=4000"); + expect(def.env?.PORT).toBe("4000"); + expect(def.env?.PHX_HTTP_PORT).toBe("4000"); + expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); expect(args).toContain("54328:4000"); - expect(args).toContain("LOGFLARE_NODE_HOST=0.0.0.0"); - expect(args.at(-1)).toBe( - `cat <<'EOF' > /tmp/run.sh && sh /tmp/run.sh\n./logflare eval Logflare.Release.migrate &&\n./logflare start --sname logflare\nEOF\n`, - ); }); it("keeps analytics on its container port when Linux uses bridge networking", () => { const def = makeAnalyticsServiceDocker({ + runtime: "docker", image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), identity: EPHEMERAL_IDENTITY, hostPort: 54328, @@ -760,15 +619,16 @@ describe("docker-backed auxiliary services", () => { dependencies: [{ service: "postgres", condition: "healthy" }], }); - expect(def.args).toContain("PORT=4000"); - expect(def.args).toContain("PHX_HTTP_PORT=4000"); - expect(def.args).toContain("LOGFLARE_NODE_HOST=0.0.0.0"); + expect(def.env?.PORT).toBe("4000"); + expect(def.env?.PHX_HTTP_PORT).toBe("4000"); + expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); expect(def.args).toContain("host.docker.internal:host-gateway"); expect(def.args).toContain("54328:4000"); }); it("keeps pooler container ports fixed and maps only the selected proxy port outward", () => { const def = makePoolerServiceDocker({ + runtime: "docker", image: dockerImageForService("pooler", DEFAULT_VERSIONS.pooler), identity: EPHEMERAL_IDENTITY, hostAdminPort: 54329, @@ -793,9 +653,9 @@ describe("docker-backed auxiliary services", () => { path: "/api/health", scheme: "http", }); - expect(def.args).toContain(`PORT=${poolerContainerPorts.admin}`); - expect(def.args).toContain(`PROXY_PORT_SESSION=${poolerContainerPorts.session}`); - expect(def.args).toContain(`PROXY_PORT_TRANSACTION=${poolerContainerPorts.transaction}`); + expect(def.env?.PORT).toBe(String(poolerContainerPorts.admin)); + expect(def.env?.PROXY_PORT_SESSION).toBe(String(poolerContainerPorts.session)); + expect(def.env?.PROXY_PORT_TRANSACTION).toBe(String(poolerContainerPorts.transaction)); expect(def.args).toContain(`54329:${poolerContainerPorts.admin}`); expect(def.args).toContain(`54330:${poolerContainerPorts.transaction}`); }); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index b0da6a5997..e739aa962e 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -2,10 +2,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerStorageOptions { +interface DockerStorageOptions extends ContainerRuntimeOptions { readonly image: string; readonly port: number; readonly identity: StackIdentity; @@ -46,6 +50,7 @@ const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "storage", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index c1223a4882..bb5f1009d5 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerStudioOptions { +interface DockerStudioOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly port: number; @@ -37,6 +41,7 @@ const studioHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "studio", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts index b0c5841d5c..de4fc9bf93 100644 --- a/packages/stack/src/services/vector.ts +++ b/packages/stack/src/services/vector.ts @@ -1,14 +1,16 @@ -import { existsSync } from "node:fs"; +import { accessSync, constants } from "node:fs"; import { dockerNetworkArgs } from "../Platform.ts"; +import type { ContainerRuntime } from "../ContainerRuntime.ts"; import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; import { dockerExecHealthCheck, dockerRunService, + type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerVectorOptions { +interface DockerVectorOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly serviceHost: string; @@ -18,19 +20,24 @@ interface DockerVectorOptions { readonly dependencies: ReadonlyArray; } -const VECTOR_CONFIG = (host: string, port: number, apiKey: string) => `api: +const vectorConfig = ( + host: string, + port: number, + apiKey: string, + logSource: "docker_logs" | "internal_logs", +) => `api: enabled: true address: 0.0.0.0:9001 sources: - docker: - type: docker_logs + runtime: + type: ${logSource} sinks: logflare: type: http inputs: - - docker + - runtime encoding: codec: json method: post @@ -41,31 +48,76 @@ sinks: uri: "http://${host}:${port}/api/logs?source_name=docker.logs.local" `; +const canAccessSocket = (socket: string): boolean => { + try { + accessSync(socket, constants.R_OK | constants.W_OK); + return true; + } catch { + return false; + } +}; + +const unixSocketFromEnv = (value: string | undefined): string | undefined => { + if (value === undefined || !value.startsWith("unix://")) return undefined; + const socket = value.slice("unix://".length); + return socket.length > 0 && canAccessSocket(socket) ? socket : undefined; +}; + +const podmanSocketCandidates = (): ReadonlyArray => { + const candidates: Array = []; + const runtimeDir = process.env.XDG_RUNTIME_DIR; + if (runtimeDir !== undefined && runtimeDir.length > 0) { + candidates.push(`${runtimeDir}/podman/podman.sock`); + } + const uid = process.getuid?.(); + if (uid !== undefined) candidates.push(`/run/user/${uid}/podman/podman.sock`); + candidates.push("/run/podman/podman.sock"); + return candidates; +}; + +const resolveVectorDockerSocket = (runtime: ContainerRuntime): string | undefined => { + if (runtime === "podman") { + const explicitPodmanSocket = unixSocketFromEnv(process.env.CONTAINER_HOST); + if (explicitPodmanSocket !== undefined) return explicitPodmanSocket; + const explicitDockerSocket = unixSocketFromEnv(process.env.DOCKER_HOST); + if (explicitDockerSocket !== undefined) return explicitDockerSocket; + return podmanSocketCandidates().find(canAccessSocket); + } + + const explicitDockerSocket = unixSocketFromEnv(process.env.DOCKER_HOST); + if (explicitDockerSocket !== undefined) return explicitDockerSocket; + return canAccessSocket("/var/run/docker.sock") ? "/var/run/docker.sock" : undefined; +}; + export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { const containerName = dockerContainerName("vector", opts.identity.key); - const dockerSocket = process.env.DOCKER_HOST?.startsWith("unix://") - ? process.env.DOCKER_HOST.slice("unix://".length) - : "/var/run/docker.sock"; - const volumes = existsSync(dockerSocket) ? [`${dockerSocket}:/var/run/docker.sock:ro`] : []; + const socketPath = resolveVectorDockerSocket(opts.runtime); + const volumes = socketPath === undefined ? [] : [`${socketPath}:/var/run/docker.sock:ro`]; return dockerRunService({ + runtime: opts.runtime, name: "vector", identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, []), volumes, - env: { - DOCKER_HOST: "unix:///var/run/docker.sock", - }, + securityOptions: opts.runtime === "podman" && socketPath !== undefined ? ["label=disable"] : [], + env: socketPath === undefined ? {} : { DOCKER_HOST: "unix:///var/run/docker.sock" }, entrypoint: "sh", cmd: [ "-c", `cat <<'EOF' > /etc/vector/vector.yaml && vector --config /etc/vector/vector.yaml -${VECTOR_CONFIG(opts.serviceHost, opts.analyticsPort, opts.analyticsApiKey)}EOF +${vectorConfig( + opts.serviceHost, + opts.analyticsPort, + opts.analyticsApiKey, + socketPath === undefined ? "internal_logs" : "docker_logs", +)}EOF `, ], dependencies: opts.dependencies, healthCheck: dockerExecHealthCheck( + opts.runtime, containerName, "sh", ["-ec", "wget -q -O /dev/null http://127.0.0.1:9001/health"], diff --git a/packages/stack/src/services/vector.unit.test.ts b/packages/stack/src/services/vector.unit.test.ts new file mode 100644 index 0000000000..069d232bc9 --- /dev/null +++ b/packages/stack/src/services/vector.unit.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { stackIdentity } from "../StackIdentity.ts"; +import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; +import { makeVectorServiceDocker } from "./vector.ts"; + +const existingPaths = vi.hoisted(() => new Set()); +const accessiblePaths = vi.hoisted(() => new Set()); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (path: Parameters[0]) => existingPaths.has(String(path)), + accessSync: (path: Parameters[0]) => { + const socket = String(path); + if (!existingPaths.has(socket) || !accessiblePaths.has(socket)) { + throw new Error("socket is not accessible"); + } + }, + }; +}); + +const identity = stackIdentity({ apiPort: 54321 }); + +const makeVector = (runtime: "docker" | "podman") => + makeVectorServiceDocker({ + runtime, + image: dockerImageForService("vector", DEFAULT_VERSIONS.vector), + identity, + serviceHost: "127.0.0.1", + analyticsPort: 54327, + analyticsApiKey: "test-api-key", + platformOs: "linux", + dependencies: [], + }); + +describe("makeVectorServiceDocker log source", () => { + beforeEach(() => { + existingPaths.clear(); + accessiblePaths.clear(); + vi.stubEnv("CONTAINER_HOST", ""); + vi.stubEnv("DOCKER_HOST", ""); + vi.stubEnv("XDG_RUNTIME_DIR", ""); + }); + + afterEach(() => { + existingPaths.clear(); + accessiblePaths.clear(); + vi.unstubAllEnvs(); + }); + + it("uses internal_logs when Podman cannot find its own socket", () => { + existingPaths.add("/var/run/docker.sock"); + accessiblePaths.add("/var/run/docker.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: internal_logs"); + expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(def.env?.DOCKER_HOST).toBeUndefined(); + expect(args).not.toContain("--security-opt"); + }); + + it("connects Podman Vector to an available Podman socket", () => { + existingPaths.add("/run/podman/podman.sock"); + accessiblePaths.add("/run/podman/podman.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: docker_logs"); + expect(args).toContain("/run/podman/podman.sock:/var/run/docker.sock:ro"); + expect(def.env?.DOCKER_HOST).toBe("unix:///var/run/docker.sock"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).toContain("--security-opt"); + expect(args).toContain("label=disable"); + }); + + it("uses internal_logs when Docker cannot find its socket", () => { + const def = makeVector("docker"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: internal_logs"); + expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(def.env?.DOCKER_HOST).toBeUndefined(); + expect(args).not.toContain("--security-opt"); + }); + + it("uses internal_logs when the Podman socket is not readable and writable", () => { + existingPaths.add("/run/podman/podman.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: internal_logs"); + expect(args).not.toContain("/run/podman/podman.sock:/var/run/docker.sock:ro"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(def.env?.DOCKER_HOST).toBeUndefined(); + expect(args).not.toContain("--security-opt"); + }); + + it("honors an explicit Docker socket for Podman Vector", () => { + existingPaths.add("/var/run/docker.sock"); + accessiblePaths.add("/var/run/docker.sock"); + vi.stubEnv("DOCKER_HOST", "unix:///var/run/docker.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: docker_logs"); + expect(args).toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); + expect(def.env?.DOCKER_HOST).toBe("unix:///var/run/docker.sock"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).toContain("--security-opt"); + expect(args).toContain("label=disable"); + }); +}); diff --git a/packages/stack/src/stackHandle.ts b/packages/stack/src/stackHandle.ts new file mode 100644 index 0000000000..2f94fbce68 --- /dev/null +++ b/packages/stack/src/stackHandle.ts @@ -0,0 +1,55 @@ +import type { LogEntry } from "@supabase/process-compose"; +import { Effect, Stream } from "effect"; +import type { FunctionsReloadConfig } from "./functions.ts"; +import type { ForegroundStackHandle } from "./createStack.ts"; +import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; +import type { ReadyOptions } from "./StackConfig.ts"; +import type { StackServiceState } from "./StackServiceState.ts"; + +/** Public Promise/AsyncIterable stack surface for Node and Bun consumers. */ +export interface StackHandle extends AsyncDisposable { + readonly url: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + start(): Promise; + stop(): Promise; + dispose(): Promise; + startService(name: string): Promise; + stopService(name: string): Promise; + restartService(name: string): Promise; + reloadFunctions(opts?: FunctionsReloadConfig): Promise; + reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; + ready(opts?: ReadyOptions): Promise; + serviceReady(name: string, opts?: ReadyOptions): Promise; + getStatus(): Promise>; + getServiceStatus(name: string): Promise; + statusChanges(): AsyncIterable; + logs(): AsyncIterable; + serviceLogs(name: string): AsyncIterable; + logHistory(name: string, limit?: number): Promise>; +} + +export const toStackHandle = (handle: ForegroundStackHandle): StackHandle => ({ + url: handle.url, + dbUrl: handle.dbUrl, + publishableKey: handle.publishableKey, + secretKey: handle.secretKey, + start: () => Effect.runPromise(handle.start()), + stop: () => Effect.runPromise(handle.stop()), + dispose: () => Effect.runPromise(handle.dispose()), + startService: (name) => Effect.runPromise(handle.startService(name)), + stopService: (name) => Effect.runPromise(handle.stopService(name)), + restartService: (name) => Effect.runPromise(handle.restartService(name)), + reloadFunctions: (opts) => Effect.runPromise(handle.reloadFunctions(opts)), + reloadEdgeRuntime: (opts) => Effect.runPromise(handle.reloadEdgeRuntime(opts)), + ready: (opts) => Effect.runPromise(handle.ready(opts)), + serviceReady: (name, opts) => Effect.runPromise(handle.serviceReady(name, opts)), + getStatus: () => Effect.runPromise(handle.getStatus()), + getServiceStatus: (name) => Effect.runPromise(handle.getServiceStatus(name)), + statusChanges: () => Stream.toAsyncIterable(handle.statusChanges()), + logs: () => Stream.toAsyncIterable(handle.logs()), + serviceLogs: (name) => Stream.toAsyncIterable(handle.serviceLogs(name)), + logHistory: (name, limit) => Effect.runPromise(handle.logHistory(name, limit)), + [Symbol.asyncDispose]: () => Effect.runPromise(handle.dispose()), +}); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index e8b0e28cd7..976e62d555 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -1,4 +1,4 @@ -import { Cause, Context, Effect, Layer } from "effect"; +import { Cause, Context, Effect, Exit, Layer } from "effect"; import { NodeFileSystem } from "@effect/platform-node"; import { fork, type ChildProcess } from "node:child_process"; import { createServer as createHttpServer } from "node:http"; @@ -7,13 +7,11 @@ import { cpSync, chmodSync, existsSync, - type FSWatcher, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, - watch, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -25,12 +23,13 @@ import { Stack } from "./Stack.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; import { managedDaemonLayer } from "./supervisor.ts"; -import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts"; -import { resolveConfig } from "./StackConfigResolver.ts"; +import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; +import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; import { git } from "../tests/helpers/git-workspace.ts"; +import { watchDirectoryWithRetry } from "../tests/helpers/file-watch.ts"; const childEntryPoint = fileURLToPath( new URL("../tests/helpers/supervisor-child.ts", import.meta.url), @@ -41,12 +40,16 @@ const errorChildEntryPoint = fileURLToPath( const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; const FILE_WAIT_TIMEOUT_MS = 30_000; +const resolveConfig = (...args: Parameters) => + Effect.runPromise(resolveConfigEffect(...args).pipe(Effect.provide(NodeFileSystem.layer))); + type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; interface ChildHandle { readonly child: ChildProcess; readonly started: Promise; readonly attachedBeforeReady: Promise; + readonly managedStarted: Promise; } const workspace = async (): Promise<{ @@ -89,43 +92,6 @@ const workspace = async (): Promise<{ throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts"); }; -/** - * Watches a directory, re-arming on ENOENT watcher errors: the runtime's - * directory watcher can report ENOENT when a watched entry (for example an - * atomic-write temp file) vanishes mid-scan. Callers keep their own timeout - * as the guard. Returns a close function. - */ -const watchDirectoryWithRetry = ( - directory: string, - onEvent: () => void, - onError: (cause: unknown) => void, -): (() => void) => { - let watcher: FSWatcher | undefined; - let closed = false; - const arm = () => { - if (closed) return; - try { - watcher = watch(directory, () => onEvent()); - watcher.once("error", (cause) => { - watcher?.close(); - if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { - arm(); - onEvent(); - return; - } - onError(cause); - }); - } catch (cause) { - onError(cause); - } - }; - arm(); - return () => { - closed = true; - watcher?.close(); - }; -}; - const waitForFile = (path: string): Promise => new Promise((resolve, reject) => { if (existsSync(path)) { @@ -253,41 +219,45 @@ const spawnChild = ( child.once("error", onError); child.once("exit", onExit); }); - const attachedBeforeReady = new Promise((resolve, reject) => { - const onMessage = (value: unknown) => { - if ( - typeof value === "object" && - value !== null && - "type" in value && - value.type === "test-stage" && - "stage" in value && - value.stage === "attached-before-ready" - ) { + const waitForStage = (stage: "attached-before-ready" | "managed-started") => + new Promise((resolve, reject) => { + const onMessage = (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "type" in value && + value.type === "test-stage" && + "stage" in value && + value.stage === stage + ) { + cleanup(); + resolve(); + } + }; + const cleanup = () => { + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + }; + const onError = (cause: Error) => { cleanup(); - resolve(); - } - }; - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const onError = (cause: Error) => { - cleanup(); - reject(cause); - }; - const onExit = (code: number | null) => { - cleanup(); - reject(new Error(`supervisor exited before attach wait stage (${String(code)})`)); - }; - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - }); + reject(cause); + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`supervisor exited before ${stage} stage (${String(code)})\n${stderr}`)); + }; + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + }); + const attachedBeforeReady = waitForStage("attached-before-ready"); + const managedStarted = waitForStage("managed-started"); void started.catch(() => undefined); void attachedBeforeReady.catch(() => undefined); + void managedStarted.catch(() => undefined); child.send(input); - return { child, started, attachedBeforeReady }; + return { child, started, attachedBeforeReady, managedStarted }; }; const kill = (child: ChildProcess): Promise => @@ -342,7 +312,6 @@ const remoteInfo = (endpoint: ControlEndpoint): Promise<{ readonly url: string } const updateLaunch = async ( endpoint: ControlEndpoint, launch: { - readonly mode: "native" | "auto" | "docker"; readonly versions: Record; }, ): Promise => { @@ -384,24 +353,21 @@ const bindFakeOwner = async ( endpoint: ControlEndpoint, makeServer: () => ReturnType, ): Promise> => { - const deadline = Date.now() + 10_000; - do { - const server = makeServer(); - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(endpoint.port, endpoint.hostname, () => { - server.off("error", reject); - resolve(); - }); - }); - return server; - } catch { - server.close(); - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } while (Date.now() < deadline); - throw new Error(`timed out binding fake owner at ${endpoint.url}`); + const server = makeServer(); + await new Promise((resolve, reject) => { + const onError = (cause: Error) => { + server.off("listening", onListening); + reject(new Error(`unable to bind fake owner at ${endpoint.url}: ${cause.message}`)); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(endpoint.port, endpoint.hostname); + }); + return server; }; const listenStartingOwner = ( @@ -497,19 +463,23 @@ const readStackDocument = (roots: { | { readonly id: string; readonly lifecycle: string; - readonly ports: ReadonlyArray<{ port: number }>; - readonly launch?: { readonly mode: string; readonly versions: Record }; + readonly ports: ReadonlyArray<{ key: string; port: number }>; + readonly launch?: { + readonly mode: string; + readonly containerRuntime?: string; + readonly versions: Record; + }; } | undefined => { const stacksRoot = join(roots.stateRoot, "stacks"); if (!existsSync(stacksRoot)) return undefined; for (const id of readdirSync(stacksRoot)) { - const path = managedStackDocumentPath(roots.stateRoot, id); + const path = Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, id)); if (!existsSync(path)) continue; return JSON.parse(readFileSync(path, "utf8")) as { readonly id: string; readonly lifecycle: string; - readonly ports: ReadonlyArray<{ port: number }>; + readonly ports: ReadonlyArray<{ key: string; port: number }>; }; } return undefined; @@ -526,7 +496,9 @@ const waitForStackDocument = async ( roots: { readonly stateRoot: string; readonly stackId: string }, lifecycle: string, ): Promise => { - const documentPath = managedStackDocumentPath(roots.stateRoot, roots.stackId); + const documentPath = Effect.runSync( + managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), + ); const stackDirectory = dirname(documentPath); await waitForFile(dirname(stackDirectory)); await waitForFile(stackDirectory); @@ -579,8 +551,8 @@ describe("detached supervisor child journeys", () => { Effect.provide(NodeFileSystem.layer), ), ); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "InvalidManagedStackNameError", }); @@ -599,8 +571,8 @@ describe("detached supervisor child journeys", () => { Effect.provide(NodeFileSystem.layer), ), ); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SupervisorStartError", message: "Supervisor test runtime failed after binding", @@ -614,7 +586,7 @@ describe("detached supervisor child journeys", () => { test("keeps managed documents, runtime metadata, and persistent data roots separate", async () => { const roots = await workspace(); const stackId = "e".repeat(64); - const paths = managedStackPaths(roots.stateRoot, stackId); + const paths = Effect.runSync(managedStackPathsEffect(roots.stateRoot, stackId)); try { const resolved = await resolveConfig( { @@ -634,10 +606,10 @@ describe("detached supervisor child journeys", () => { { stackRoot: paths.root, runtimeRoot: paths.runtime, - portAllocator: () => Effect.succeed({ apiPort: 55001, dbPort: 55002 }), + ports: { apiPort: 55001, dbPort: 55002 }, }, ); - expect(managedStackDocumentPath(roots.stateRoot, stackId)).toBe( + expect(Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, stackId))).toBe( join(paths.root, "stack.json"), ); expect(resolved.postgres.dataDir.startsWith(join(paths.root, "data"))).toBe(true); @@ -673,23 +645,284 @@ describe("detached supervisor child journeys", () => { } }); + test("starts an omitted-mode stack from one detected runtime selection", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-runtime-")); + const docker = join(binDir, "docker"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + chmodSync(docker, 0o755); + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const child = spawnChild( + messageFor(roots, { + config: { ...config, edgeRuntime: { inspectorPort: 8_123 } }, + }), + { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, + ); + try { + const started = await child.started; + const document = readStackDocument(roots); + expect(document?.launch).toMatchObject({ + mode: "docker", + containerRuntime: "docker", + }); + expect(document?.ports).toContainEqual( + expect.objectContaining({ key: "edge_runtime.inspector_port", intent: "automatic" }), + ); + await remoteStop(started.endpoint); + await waitForExit(child.child); + + const explicit = spawnChild( + messageFor(roots, { + config: { ...config, edgeRuntime: { inspectorPort: 8_123 } }, + portIntents: { + ...base.portIntents, + document: { edge_runtime: { inspector_port: 8_123 } }, + }, + }), + { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, + ); + try { + const explicitStarted = await explicit.started; + const explicitDocument = readStackDocument(roots); + expect(explicitDocument?.ports).toContainEqual({ + key: "edge_runtime.inspector_port", + port: 8_123, + intent: "exact", + }); + await remoteStop(explicitStarted.endpoint); + await waitForExit(explicit.child); + } finally { + if (explicit.child.exitCode === null) await kill(explicit.child); + } + } finally { + if (child.child.exitCode === null) await kill(child.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("falls back to the native service set when no container runtime is usable", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-fallback-")); + for (const runtime of ["docker", "podman"]) { + const executable = join(binDir, runtime); + writeFileSync(executable, "#!/bin/sh\nexit 1\n"); + chmodSync(executable, 0o755); + } + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const child = spawnChild( + messageFor(roots, { + config: { + ...config, + auth: {}, + postgrest: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, + }, + }), + { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, + ); + try { + const started = await child.started; + expect(readStackDocument(roots)?.launch).toMatchObject({ mode: "native" }); + await remoteStop(started.endpoint); + await waitForExit(child.child); + } finally { + if (child.child.exitCode === null) await kill(child.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("does not discard an explicit Edge Runtime request during native fallback", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-edge-runtime-")); + for (const runtime of ["docker", "podman"]) { + const executable = join(binDir, runtime); + writeFileSync(executable, "#!/bin/sh\nexit 1\n"); + chmodSync(executable, 0o755); + } + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const child = spawnChild( + messageFor(roots, { config: { ...config, edgeRuntime: { inspectorPort: 8_123 } } }), + { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, + ); + try { + await expect(child.started).rejects.toThrow("Native mode supports only"); + await waitForExit(child.child); + } finally { + if (child.child.exitCode === null) await kill(child.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("does not discard an explicit Docker-only service request during native fallback", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-storage-")); + for (const runtime of ["docker", "podman"]) { + const executable = join(binDir, runtime); + writeFileSync(executable, "#!/bin/sh\nexit 1\n"); + chmodSync(executable, 0o755); + } + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const child = spawnChild( + messageFor(roots, { + config: { ...config, storage: { dataDir: join(roots.root, "storage") } }, + }), + { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, + ); + try { + await expect(child.started).rejects.toThrow("Native mode supports only"); + await waitForExit(child.child); + } finally { + if (child.child.exitCode === null) await kill(child.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("rejects an explicit mode change before attaching to a running owner", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-mode-attach-")); + const docker = join(binDir, "docker"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + chmodSync(docker, 0o755); + const nativeInput = messageFor(roots); + const dockerInput = messageFor(roots, { + config: { ...nativeInput.config, mode: "docker" }, + }); + const environment = { PATH: `${binDir}:${process.env["PATH"] ?? ""}` }; + const owner = spawnChild(dockerInput, { environment }); + let contender: ChildHandle | undefined; + let sameMode: ChildHandle | undefined; + try { + const started = await owner.started; + contender = spawnChild(nativeInput, { environment }); + await expect(contender.started).rejects.toThrow( + "Stack runtime is already docker; requested native", + ); + await waitForExit(contender.child); + + sameMode = spawnChild(dockerInput, { environment }); + const attached = await sameMode.started; + expect(attached.attached).toBe(true); + await remoteStop(started.endpoint); + await Promise.all([waitForExit(owner.child), waitForExit(sameMode.child)]); + } finally { + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + if (sameMode?.child.exitCode === null) await kill(sameMode.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("rechecks the winner's mode when attach starts before its first document", async () => { + const roots = await workspace(); + const ensureReady = join(roots.root, "ensure-ready"); + const ensureRelease = join(roots.root, "ensure-release"); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-mode-first-launch-")); + const docker = join(binDir, "docker"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + chmodSync(docker, 0o755); + const nativeInput = messageFor(roots); + const dockerInput = messageFor(roots, { + config: { ...nativeInput.config, mode: "docker" }, + }); + const environment = { PATH: `${binDir}:${process.env.PATH ?? ""}` }; + const owner = spawnChild(dockerInput, { + environment: { + ...environment, + SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, + SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, + }, + }); + let contender: ChildHandle | undefined; + try { + await waitForFile(ensureReady); + contender = spawnChild(nativeInput, { environment }); + await contender.attachedBeforeReady; + + writeFileSync(ensureRelease, "release"); + await owner.started; + await expect(contender.started).rejects.toThrow( + "Stack runtime is already docker; requested native", + ); + await waitForExit(contender.child); + await remoteStop((await owner.started).endpoint); + await waitForExit(owner.child); + } finally { + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("reuses the persisted runtime instead of selecting a different one on restart", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-sticky-runtime-")); + const docker = join(binDir, "docker"); + const podman = join(binDir, "podman"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + writeFileSync(podman, "#!/bin/sh\nexit 1\n"); + chmodSync(docker, 0o755); + chmodSync(podman, 0o755); + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const input = messageFor(roots, { config }); + const environment = { PATH: `${binDir}:${process.env["PATH"] ?? ""}` }; + const initial = spawnChild(input, { environment }); + let restarted: ChildHandle | undefined; + try { + const started = await initial.started; + await remoteStop(started.endpoint); + await waitForExit(initial.child); + + writeFileSync(docker, "#!/bin/sh\nexit 1\n"); + writeFileSync(podman, "#!/bin/sh\nexit 0\n"); + restarted = spawnChild(input, { environment }); + + await expect(restarted.started).rejects.toThrow( + "Docker mode requires a usable docker runtime", + ); + expect(readStackDocument(roots)?.launch).toMatchObject({ + mode: "docker", + containerRuntime: "docker", + }); + } finally { + if (initial.child.exitCode === null) await kill(initial.child); + if (restarted?.child.exitCode === null) await kill(restarted.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + test("publishes stopping before a slow owner shutdown can finish", async () => { const roots = await workspace(); - const child = spawnChild(messageFor(roots), { testMode: "hold-stop" }); + const stopBegan = join(roots.root, "stop-began"); + const child = spawnChild(messageFor(roots), { + testMode: "hold-stop", + environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, + }); try { const started = await child.started; expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "running", ready: true }); void fetch(`${started.endpoint.url}/stop`, { method: "POST" }).catch(() => undefined); - const deadline = Date.now() + 2_000; - let stopping = false; - while (Date.now() < deadline) { - if ((await fetchOwner(started.endpoint).catch(() => undefined))?.state === "stopping") { - stopping = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(stopping).toBe(true); + await waitForFile(stopBegan); + expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); } finally { if (child.child.exitCode === null) await kill(child.child); cleanupRoots(roots); @@ -698,7 +931,12 @@ describe("detached supervisor child journeys", () => { test("Bun routes a ready-owner stop through the daemon shutdown transaction", async () => { const roots = await workspace(); - const child = spawnChild(messageFor(roots), { testMode: "hold-stop", platform: "bun" }); + const stopBegan = join(roots.root, "stop-began"); + const child = spawnChild(messageFor(roots), { + testMode: "hold-stop", + platform: "bun", + environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, + }); try { const started = await child.started; let responseSettled = false; @@ -711,13 +949,7 @@ describe("detached supervisor child journeys", () => { responseSettled = true; return undefined; }); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if ((await fetchOwner(started.endpoint).catch(() => undefined))?.state === "stopping") { - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); expect(responseSettled).toBe(false); await kill(child.child); @@ -731,22 +963,20 @@ describe("detached supervisor child journeys", () => { test("starts after an owner finishes stopping", async () => { const roots = await workspace(); const releaseFile = join(roots.root, "release-stop"); + const stopBegan = join(roots.root, "stop-began"); const input = messageFor(roots); const owner = spawnChild(input, { testMode: "hold-stop", - environment: { SUPABASE_STACK_TEST_STOP_RELEASE_FILE: releaseFile }, + environment: { + SUPABASE_STACK_TEST_STOP_RELEASE_FILE: releaseFile, + SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan, + }, }); let contender: ChildHandle | undefined; try { const started = await owner.started; const stop = fetch(`${started.endpoint.url}/stop`, { method: "POST" }).catch(() => undefined); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if ((await fetchOwner(started.endpoint).catch(() => undefined))?.state === "stopping") { - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); contender = spawnChild(input); @@ -863,7 +1093,9 @@ describe("detached supervisor child journeys", () => { await contender.attachedBeforeReady; rmSync(originalGit, { recursive: true, force: true }); - const documentPath = managedStackDocumentPath(roots.stateRoot, starting.id); + const documentPath = Effect.runSync( + managedStackDocumentPathEffect(roots.stateRoot, starting.id), + ); const document = JSON.parse(readFileSync(documentPath, "utf8")) as Record; writeFileSync(documentPath, JSON.stringify({ ...document, lifecycle: "running" })); releaseFakeOwner?.(); @@ -920,7 +1152,10 @@ describe("detached supervisor child journeys", () => { await Promise.race([ waitForExit(child.child), new Promise((_, reject) => - setTimeout(() => reject(new Error("owner did not stop")), 2_000), + setTimeout( + () => reject(new Error(`owner did not stop within ${FILE_WAIT_TIMEOUT_MS}ms`)), + FILE_WAIT_TIMEOUT_MS, + ), ), ]); const stopped = await waitForStackDocument(roots, "stopped"); @@ -1015,16 +1250,55 @@ describe("detached supervisor child journeys", () => { await kill(owner.child); await expect(owner.started).rejects.toThrow(); - const deadline = Date.now() + 3_000; - let restarted = false; - while (Date.now() < deadline) { - if ((await fetchOwner(restartedEndpoint).catch(() => undefined))?.state === "starting") { - restarted = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - expect(restarted).toBe(true); + await contender.managedStarted; + expect(await fetchOwner(restartedEndpoint)).toMatchObject({ state: "starting" }); + } finally { + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + cleanupRoots(roots); + } + }); + + test("re-reads persisted Docker state after taking over an owner that published during attach", async () => { + const roots = await workspace(); + const ensureReady = join(roots.root, "ensure-ready"); + const ensureRelease = join(roots.root, "ensure-release"); + const dockerBin = join(roots.root, "fake-docker-bin"); + const dockerCleanup = join(roots.root, "docker-cleanup"); + mkdirSync(dockerBin); + const docker = join(dockerBin, "docker"); + writeFileSync( + docker, + `#!/bin/sh\nif [ "$1" = "rm" ]; then printf cleaned > "${dockerCleanup}"; fi\nexit 0\n`, + ); + chmodSync(docker, 0o755); + const nativeInput = messageFor(roots); + const input = messageFor(roots, { + config: { ...nativeInput.config, mode: "docker" }, + }); + const environment = { PATH: `${dockerBin}:${process.env.PATH ?? ""}` }; + const owner = spawnChild(input, { + testMode: "hold-start", + environment: { + ...environment, + SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, + SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, + }, + }); + void owner.started.catch(() => undefined); + let contender: ChildHandle | undefined; + try { + await waitForFile(ensureReady); + contender = spawnChild(input, { testMode: "hold-start", environment }); + void contender.started.catch(() => undefined); + await contender.attachedBeforeReady; + + writeFileSync(ensureRelease, "release"); + await owner.managedStarted; + await kill(owner.child); + await contender.managedStarted; + + expect(readFileSync(dockerCleanup, "utf8")).toBe("cleaned"); } finally { if (owner.child.exitCode === null) await kill(owner.child); if (contender?.child.exitCode === null) await kill(contender.child); @@ -1150,9 +1424,9 @@ describe("detached supervisor child journeys", () => { const attached = await later.started; expect(attached.attached).toBe(true); expect(await remoteInfo(attached.endpoint)).toMatchObject({ url: expect.any(String) }); - await updateLaunch(attached.endpoint, { mode: "auto", versions: { postgres: "17.6.1" } }); + await updateLaunch(attached.endpoint, { versions: { postgres: "17.6.1" } }); expect(readStackDocument(roots)?.launch).toEqual({ - mode: "auto", + mode: "native", versions: { postgres: "17.6.1" }, }); await remoteStop(attached.endpoint); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index 478b489ac9..1bd28c9fcd 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -7,11 +7,18 @@ import { Effect, Fiber, Layer, + Predicate, Schedule, Scope, Schema, } from "effect"; import { HttpServer } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + selectStackRuntime, + validateStackRuntime, + type StackRuntimeSelection, +} from "./ContainerRuntime.ts"; import type { PlatformFactory } from "./createStack.ts"; import { DaemonServer } from "./DaemonServer.ts"; import { Stack } from "./Stack.ts"; @@ -25,17 +32,30 @@ import { type ControlOwnership, type ControlTransport, } from "./managed/control.ts"; -import { ManagedStackManager, type ManagedStackStartResult } from "./managed/manager.ts"; -import { managedStackLaunchSchema } from "./managed/document.ts"; +import { + ManagedStackManager, + type ManagedStackManagerConstructionError, + type ManagedStackStartResult, +} from "./managed/manager.ts"; +import { + managedStackLaunchInputSchema, + type ManagedStackLaunch, + type ManagedStackLaunchInput, +} from "./managed/document.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { validateManagedStackName, type ManagedPortIntentDocument } from "./managed/model.ts"; -import { managedStackPaths } from "./managed/paths.ts"; -import { PORT_FIELDS, type PortField, type PortSet } from "./PortCatalog.ts"; -import { SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { managedStackPathsEffect } from "./managed/paths.ts"; +import { PORT_CATALOG, PORT_FIELDS } from "./PortCatalog.ts"; +import { portFieldsForConfigInput } from "./ServicePorts.ts"; +import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; import { dockerContainerName } from "./StackIdentity.ts"; -import type { PortAllocationError, PortLease } from "./PortAllocator.ts"; -import { resolveConfig, type DaemonConfigInput } from "./StackConfigResolver.ts"; +import type { PortLease } from "./PortAllocator.ts"; +import { + portRequestsForConfig, + resolveConfig, + type DaemonConfigInput, +} from "./StackConfigResolver.ts"; import type { ResolvedDaemonConfig } from "./StackConfig.ts"; import { HttpTransportClient } from "./HttpTransportClient.ts"; import { RemoteStack } from "./RemoteStack.ts"; @@ -51,7 +71,7 @@ export interface SupervisorStartMessage { readonly stateRoot: string; readonly config: Readonly>; readonly portIntents: ManagedPortIntentDocument; - readonly launch?: import("./managed/document.ts").ManagedStackDocument["launch"]; + readonly launch?: ManagedStackLaunchInput; } export interface SupervisorStartedMessage { @@ -73,7 +93,7 @@ export interface ManagedDaemonStartInput { readonly stateRoot: string; readonly config: Readonly>; readonly portIntents: ManagedPortIntentDocument; - readonly launch?: import("./managed/document.ts").ManagedStackDocument["launch"]; + readonly launch?: ManagedStackLaunchInput; } const supervisorPortIntentSchema = Schema.Struct({ @@ -90,7 +110,7 @@ const supervisorStartMessageSchema = Schema.Struct({ stateRoot: Schema.String, config: Schema.Record(Schema.String, Schema.Unknown), portIntents: supervisorPortIntentSchema, - launch: Schema.optionalKey(managedStackLaunchSchema), + launch: Schema.optionalKey(managedStackLaunchInputSchema), }); const isRecord = (value: unknown): value is Readonly> => @@ -102,16 +122,71 @@ const isControlEndpoint = (value: unknown): value is ControlEndpoint => typeof value.port === "number" && typeof value.url === "string"; -const decodeSupervisorStartMessage = (value: unknown): SupervisorStartMessage => { - return Schema.decodeUnknownSync(supervisorStartMessageSchema)(value); +const isControlOwnership = (value: ControlAcquisition): value is ControlOwnership => + Predicate.isTagged(value, "Owned"); + +const isControlAttached = (value: ControlAcquisition): value is ControlAttached => + Predicate.isTagged(value, "Attached"); + +const decodeSupervisorStartMessage = ( + value: unknown, +): Effect.Effect => + Schema.decodeUnknownEffect(supervisorStartMessageSchema)(value).pipe( + Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), + ); + +const causeMessage = (cause: unknown): string => { + if (cause instanceof Error && cause.message.length > 0) return cause.message; + if ( + typeof cause === "object" && + cause !== null && + "detail" in cause && + typeof cause.detail === "string" + ) { + return cause.detail; + } + return typeof cause === "string" ? cause : String(cause); }; -const causeMessage = (cause: unknown): string => - cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause); +const runtimeSelectionForLaunch = (launch: ManagedStackLaunch): StackRuntimeSelection => + launch.mode === "native" + ? { mode: "native", containerRuntime: null } + : { mode: "docker", containerRuntime: launch.containerRuntime }; const toDaemonConfig = (value: Readonly>): DaemonConfigInput | undefined => typeof value.cwd === "string" ? { ...value, cwd: value.cwd } : undefined; +/** + * The CLI's omitted-mode defaults are empty service objects, optionally + * decorated with only a pinned version. A managed caller's non-default field + * is an explicit request and must survive fallback so native validation can + * reject it instead of silently changing the requested stack. + */ +const isCatalogDefaultServiceConfig = (value: unknown): boolean => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return Object.keys(value).every((key) => key === "version"); +}; + +const nativeFallbackConfig = (config: DaemonConfigInput): DaemonConfigInput => { + const servicePolicies: NonNullable = { + ...config.servicePolicies, + }; + + for (const service of SERVICE_NAMES) { + const metadata = SERVICE_CATALOG[service]; + if ( + metadata.runtimeSupport === "docker-only" && + servicePolicies[service] === undefined && + isCatalogDefaultServiceConfig(config[metadata.configKey]) + ) { + servicePolicies[service] = "off"; + } + } + + return { ...config, servicePolicies }; +}; + export class SupervisorStartError extends Data.TaggedError("SupervisorStartError")<{ readonly message: string; readonly reason?: "owner-stopped"; @@ -157,7 +232,7 @@ const awaitOwnerReady = ( schedule: Schedule.spaced("25 millis").pipe( Schedule.tap(({ attempt }) => (attempt === 1 ? onWaiting : Effect.void)), ), - while: (error) => error._tag === "SupervisorOwnerUnavailableError" && error.retry, + while: (error) => Predicate.isTagged(error, "SupervisorOwnerUnavailableError") && error.retry, }), Effect.catchTag("SupervisorOwnerUnavailableError", (error) => Effect.fail(new SupervisorStartError({ message: error.detail })), @@ -178,7 +253,7 @@ export interface SupervisorPlatform { stateRoot: string, ) => Layer.Layer< ManagedStackManager, - never, + ManagedStackManagerConstructionError, ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path >; } @@ -187,11 +262,7 @@ const receiveStartMessage = (): Effect.Effect { const onMessage = (value: unknown) => { cleanup(); - try { - resume(Effect.succeed(decodeSupervisorStartMessage(value))); - } catch (cause) { - resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); - } + resume(decodeSupervisorStartMessage(value)); }; const onDisconnect = () => { cleanup(); @@ -247,18 +318,6 @@ const waitForSignal = (): Effect.Effect<"SIGINT" | "SIGTERM"> => return Effect.sync(cleanup); }); -const leaseFacade = (lease: { - readonly ports: PortSet; - readonly reserve: (fields: ReadonlyArray) => Effect.Effect; - readonly release: (fields: ReadonlyArray) => Effect.Effect; - readonly releaseAll: Effect.Effect; -}): PortLease => ({ - ports: lease.ports, - reserve: lease.reserve, - release: lease.release, - releaseAll: lease.releaseAll, -}); - const startDaemon = (input: { readonly config: ResolvedDaemonConfig; readonly lease: PortLease; @@ -266,7 +325,7 @@ const startDaemon = (input: { readonly platform: SupervisorPlatform; readonly scope: Scope.Scope; readonly launchUpdate?: ( - launch: NonNullable, + launch: import("./managed/document.ts").ManagedStackLaunchUpdate, ) => Effect.Effect; }): Effect.Effect< { readonly daemon: DaemonServer["Service"] }, @@ -310,6 +369,7 @@ const runManaged = ( | ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path + | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => { let owner: ControlOwnership | undefined; @@ -324,7 +384,7 @@ const runManaged = ( ); } const initialAcquisition = yield* acquireControl({ stackId: input.stackId }); - if (initialAcquisition._tag === "Owned") owner = initialAcquisition; + if (isControlOwnership(initialAcquisition)) owner = initialAcquisition; const manager = yield* ManagedStackManager.pipe( Effect.provide(platform.managerLayer(input.stateRoot)), ); @@ -332,13 +392,13 @@ const runManaged = ( const discovered = manager .ensureWorkspace(input.workspacePath) .pipe(Effect.map((discovery) => ({ _tag: "discovered" as const, discovery }))); - const discoveryResult = yield* initialAcquisition._tag === "Owned" + const discoveryResult = yield* isControlOwnership(initialAcquisition) ? Effect.raceFirst( discovered, initialAcquisition.stopRequested.pipe(Effect.as({ _tag: "stopped" as const })), ) : discovered; - if (discoveryResult._tag === "stopped") { + if (Predicate.isTagged(discoveryResult, "stopped")) { yield* sendMessage({ type: "error", message: STACK_STOPPED_DURING_STARTUP }); return; } @@ -348,11 +408,27 @@ const runManaged = ( new SupervisorStartError({ message: "Workspace identity changed before supervisor start" }), ); } + const requestedMode = configInput.mode ?? input.launch?.mode; + const existing = yield* manager.inspectStack(stackId); + const persistedRuntime: StackRuntimeSelection | undefined = + existing === undefined ? undefined : runtimeSelectionForLaunch(existing.launch); + if ( + isControlAttached(initialAcquisition) && + persistedRuntime !== undefined && + requestedMode !== undefined && + persistedRuntime.mode !== requestedMode + ) { + return yield* Effect.fail( + new SupervisorStartError({ + message: `Stack runtime is already ${persistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }), + ); + } let attachedOwnerWasStopping = false; const reacquireAfterDeath = (): Effect.Effect => manager.acquireControl(stackId).pipe( Effect.flatMap((candidate): Effect.Effect => { - if (candidate._tag === "Owned") return Effect.succeed(candidate); + if (isControlOwnership(candidate)) return Effect.succeed(candidate); return candidate.ownerStatus.pipe( Effect.flatMap((status): Effect.Effect => status.state === "starting" @@ -364,7 +440,7 @@ const runManaged = ( ), ), Effect.catch((error) => - error instanceof ControlTransportError && error.reason === "unreachable" + error instanceof ControlTransportError ? Effect.fail(new SupervisorOwnerReacquirePending()) : Effect.fail(error), ), @@ -375,37 +451,34 @@ const runManaged = ( while: (error) => error instanceof SupervisorOwnerReacquirePending, }), ); - const attachedResolution = - initialAcquisition._tag === "Attached" - ? initialAcquisition.ownerStatus.pipe( - Effect.tap((status) => - Effect.sync(() => { - attachedOwnerWasStopping = status.state === "stopping"; - }), - ), - Effect.flatMap((status) => - status.state === "running" && status.ready - ? Effect.succeed(status) - : awaitOwnerReady( - initialAcquisition, - platform.onAttachedBeforeReady?.() ?? Effect.void, - ), - ), - Effect.as(initialAcquisition), - Effect.catch((error) => - error instanceof ControlTransportError && error.reason === "unreachable" - ? reacquireAfterDeath() - : Effect.fail(error), - ), - ) - : Effect.succeed(initialAcquisition); + const attachedResolution = isControlAttached(initialAcquisition) + ? initialAcquisition.ownerStatus.pipe( + Effect.tap((status) => + Effect.sync(() => { + attachedOwnerWasStopping = status.state === "stopping"; + }), + ), + Effect.flatMap((status) => + status.state === "running" && status.ready + ? Effect.succeed(status) + : awaitOwnerReady( + initialAcquisition, + platform.onAttachedBeforeReady?.() ?? Effect.void, + ), + ), + Effect.as(initialAcquisition), + Effect.catch((error) => + error instanceof ControlTransportError ? reacquireAfterDeath() : Effect.fail(error), + ), + ) + : Effect.succeed(initialAcquisition); const acquisition = yield* attachedResolution.pipe( Effect.timeout(platform.resolutionTimeout ?? SUPERVISOR_STARTUP_TIMEOUT), Effect.catch((error) => typeof error === "object" && error !== null && "_tag" in error && - error._tag === "TimeoutError" + Predicate.isTagged(error, "TimeoutError") ? Effect.fail( new SupervisorStartError({ message: "Timed out resolving attached supervisor owner", @@ -414,7 +487,7 @@ const runManaged = ( : Effect.fail(error), ), ); - if (initialAcquisition._tag === "Attached") { + if (isControlAttached(initialAcquisition)) { const revalidated = yield* manager.ensureWorkspace(input.workspacePath); const revalidatedStackId = deriveStackId(revalidated.identity, input.stackName); if (revalidatedStackId !== stackId) { @@ -425,16 +498,35 @@ const runManaged = ( ); } } - if (acquisition._tag === "Attached") { + if (isControlAttached(acquisition)) { + // The first inspection can legitimately race the owner's initial + // document write. Once the owner reports ready, its persisted launch is + // the authoritative runtime contract for an explicit request. + const attachedExisting = yield* manager.inspectStack(stackId); + const attachedPersistedRuntime = + attachedExisting === undefined + ? undefined + : runtimeSelectionForLaunch(attachedExisting.launch); + if ( + requestedMode !== undefined && + (attachedPersistedRuntime === undefined || attachedPersistedRuntime.mode !== requestedMode) + ) { + const observedMode = attachedPersistedRuntime?.mode ?? "unknown"; + return yield* Effect.fail( + new SupervisorStartError({ + message: `Stack runtime is already ${observedMode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }), + ); + } yield* sendMessage({ type: "started", endpoint: acquisition.endpoint, attached: true }); process.disconnect?.(); return; } const ownership = acquisition; owner = ownership; - if (initialAcquisition._tag === "Attached" && !attachedOwnerWasStopping) { - const existing = yield* manager.inspectStack(stackId); - if (existing?.lifecycle === "stopped") { + const ownedExisting = yield* manager.inspectStack(stackId); + if (isControlAttached(initialAcquisition) && !attachedOwnerWasStopping) { + if (ownedExisting?.lifecycle === "stopped") { yield* ownership.close; return yield* Effect.fail( new SupervisorStartError({ @@ -444,46 +536,83 @@ const runManaged = ( ); } } + const ownedPersistedRuntime = + ownedExisting === undefined ? undefined : runtimeSelectionForLaunch(ownedExisting.launch); + if ( + ownedPersistedRuntime !== undefined && + requestedMode !== undefined && + ownedPersistedRuntime.mode !== requestedMode + ) { + return yield* Effect.fail( + new SupervisorStartError({ + message: `Stack runtime is already ${ownedPersistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }), + ); + } + const runtime = + ownedPersistedRuntime === undefined + ? yield* selectStackRuntime(requestedMode) + : yield* validateStackRuntime(ownedPersistedRuntime); + const runtimeConfigInput = + runtime.mode === "native" && requestedMode === undefined + ? nativeFallbackConfig(configInput) + : configInput; + const activeFields = portFieldsForConfigInput({ ...runtimeConfigInput, mode: runtime.mode }); + const activeFieldSet = new Set(activeFields); + const portIntents: ManagedPortIntentDocument = { + ...input.portIntents, + activeFields, + disabledFields: PORT_FIELDS.filter( + (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFieldSet.has(field), + ), + }; + // Validate policies and explicit ports before manager.startStack writes + // `starting` or acquires the managed lease. + yield* portRequestsForConfig(runtimeConfigInput, { runtime }); + const launchInput = input.launch ?? { versions: {} }; + const launch: ManagedStackLaunch = + runtime.mode === "native" + ? { ...launchInput, mode: "native" } + : { ...launchInput, mode: "docker", containerRuntime: runtime.containerRuntime }; const startup = Effect.gen(function* () { - const existing = yield* manager.inspectStack(stackId); if ( - existing !== undefined && - (existing.lifecycle === "starting" || - existing.lifecycle === "running" || - existing.lifecycle === "failed" || - existing.lifecycle === "deleting") + ownedExisting !== undefined && + (ownedExisting.lifecycle === "starting" || + ownedExisting.lifecycle === "running" || + ownedExisting.lifecycle === "failed" || + ownedExisting.lifecycle === "deleting") ) { - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (runtime.mode === "docker") { + yield* dockerForceRemove( + runtime.containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } } const started: ManagedStackStartResult = yield* manager.startStack({ workspacePath: input.workspacePath, stackName: input.stackName, - portDocument: input.portIntents, + portDocument: portIntents, ownership, lifecycle: "starting", - launch: input.launch, + launch, }); claimedStack = true; - const resolved = yield* Effect.tryPromise({ - try: () => - resolveConfig( - { - ...configInput, - projectDir: configInput.projectDir ?? input.workspacePath, - stackRoot: managedStackPaths(input.stateRoot, started.stack.id).root, - runtimeRoot: managedStackPaths(input.stateRoot, started.stack.id).runtime, - instanceId: started.stack.id, - }, - { portAllocator: () => Effect.succeed(started.lease.ports) }, - ), - catch: (cause) => cause, - }); + const managedPaths = yield* managedStackPathsEffect(input.stateRoot, started.stack.id); + const resolved = yield* resolveConfig( + { + ...runtimeConfigInput, + projectDir: runtimeConfigInput.projectDir ?? input.workspacePath, + stackRoot: managedPaths.root, + runtimeRoot: managedPaths.runtime, + instanceId: started.stack.id, + }, + { runtime, ports: started.lease.ports }, + ); const config: ResolvedDaemonConfig = { ...resolved, name: input.stackName, - projectDir: configInput.projectDir ?? input.workspacePath, + projectDir: runtimeConfigInput.projectDir ?? input.workspacePath, }; yield* manager.recordLifecycle(ownership, { stackId: started.stack.id, @@ -491,7 +620,7 @@ const runManaged = ( }); const built = yield* startDaemon({ config, - lease: leaseFacade(started.lease), + lease: started.lease, ownership, platform, scope, @@ -521,7 +650,7 @@ const runManaged = ( startup.pipe(Effect.map((result) => ({ _tag: "started" as const, ...result }))), ownership.stopRequested.pipe(Effect.as({ _tag: "stopped" as const })), ); - if (startupResult._tag === "stopped") { + if (Predicate.isTagged(startupResult, "stopped")) { const current = yield* manager.inspectStack(stackId); if (current !== undefined) { yield* manager.recordLifecycle(ownership, { stackId, lifecycle: "stopped" }); @@ -568,7 +697,10 @@ export const runSupervisor = ( ): Effect.Effect< void, SupervisorStartError | unknown, - ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path + | ControlTransport + | import("effect").FileSystem.FileSystem + | import("effect").Path.Path + | ChildProcessSpawner.ChildProcessSpawner > => Effect.scoped( Effect.gen(function* () { @@ -576,7 +708,7 @@ export const runSupervisor = ( const input = yield* receiveStartMessage(); yield* Effect.matchCauseEffect(runManaged(input, platform, scope), { onFailure: (cause) => - sendMessage({ type: "error", message: causeMessage(cause) }).pipe( + sendMessage({ type: "error", message: causeMessage(Cause.squash(cause)) }).pipe( Effect.andThen(Effect.failCause(cause)), ), onSuccess: Effect.succeed, @@ -689,9 +821,7 @@ export const supervisorLayer = ( ); }).pipe( Effect.onExit(() => - detached - ? Effect.void - : Effect.promise(() => terminateChildProcess(child)).pipe(Effect.ignore), + detached ? Effect.void : terminateChildProcess(child).pipe(Effect.ignore), ), ); }); diff --git a/packages/stack/src/terminateChild.ts b/packages/stack/src/terminateChild.ts index abe74bb1f0..c3eb3007ec 100644 --- a/packages/stack/src/terminateChild.ts +++ b/packages/stack/src/terminateChild.ts @@ -1,3 +1,5 @@ +import { Duration, Effect } from "effect"; + interface ChildLike { readonly pid?: number; readonly exitCode?: number | null; @@ -10,60 +12,56 @@ interface ChildLike { const hasAlreadyExited = (child: ChildLike): boolean => child.exitCode != null || child.signalCode != null; -export const terminateChildProcess = async ( +/** + * Waits for a child exit event while making listener ownership explicit. The + * callback adapter removes the listener on both completion and interruption; + * the timeout is an Effect race, so no timer survives a losing branch. + */ +const signalAndWait = ( + child: ChildLike, + signal: NodeJS.Signals, + timeoutMs: number, +): Effect.Effect => + Effect.raceFirst( + Effect.callback((resume) => { + let settled = false; + const cleanup = () => { + if (settled) return; + settled = true; + child.off("exit", onExit); + }; + const onExit = () => { + cleanup(); + resume(Effect.succeed(true)); + }; + + if (hasAlreadyExited(child)) { + resume(Effect.succeed(true)); + return Effect.void; + } + child.once("exit", onExit); + try { + child.kill(signal); + } catch { + // A child can disappear between the state check and kill call. + } + return Effect.sync(cleanup); + }), + Effect.as(Effect.sleep(Duration.millis(timeoutMs)), false), + ); + +export const terminateChildProcess = ( child: ChildLike, opts: { readonly timeoutMs?: number; } = {}, -): Promise => { - if (child.pid == null) { - return; - } - // An already-exited child never fires another `exit` event, so the waits - // below would burn their full SIGTERM + SIGKILL timeouts listening for one. - if (hasAlreadyExited(child)) { - return; - } +): Effect.Effect => { + if (child.pid == null || hasAlreadyExited(child)) return Effect.void; const timeoutMs = opts.timeoutMs ?? 1_000; - - const termExit = waitForChildExit(child, timeoutMs); - try { - child.kill("SIGTERM"); - } catch {} - - if (await termExit) { - return; - } - if (hasAlreadyExited(child)) { - return; - } - - const killExit = waitForChildExit(child, timeoutMs); - try { - child.kill("SIGKILL"); - } catch {} - - await killExit; -}; - -function waitForChildExit(child: ChildLike, timeoutMs: number): Promise { - return new Promise((resolve) => { - const onExit = () => { - cleanup(); - resolve(true); - }; - - const timeout = setTimeout(() => { - cleanup(); - resolve(false); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - child.off("exit", onExit); - }; - - child.once("exit", onExit); + return Effect.gen(function* () { + if (yield* signalAndWait(child, "SIGTERM", timeoutMs)) return; + if (hasAlreadyExited(child)) return; + yield* signalAndWait(child, "SIGKILL", timeoutMs); }); -} +}; diff --git a/packages/stack/src/terminateChild.unit.test.ts b/packages/stack/src/terminateChild.unit.test.ts index de37d37f12..7887fd2aa8 100644 --- a/packages/stack/src/terminateChild.unit.test.ts +++ b/packages/stack/src/terminateChild.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { Effect, Fiber } from "effect"; import { terminateChildProcess } from "./terminateChild.ts"; interface ChildLike { @@ -14,6 +15,10 @@ class FakeChild implements ChildLike { readonly signals: Array = []; #listeners = new Set<() => void>(); + get listenerCount(): number { + return this.#listeners.size; + } + constructor( private readonly onKill: (signal: NodeJS.Signals, child: FakeChild) => void = () => {}, ) {} @@ -47,7 +52,7 @@ describe("terminateChildProcess", () => { } }); - await terminateChildProcess(child, { timeoutMs: 100 }); + await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 100 })); expect(child.signals).toEqual(["SIGTERM"]); }); @@ -59,7 +64,7 @@ describe("terminateChildProcess", () => { } }); - await terminateChildProcess(child, { timeoutMs: 10 }); + await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 10 })); expect(child.signals).toEqual(["SIGTERM", "SIGKILL"]); }); @@ -73,7 +78,7 @@ describe("terminateChildProcess on an already-exited child", () => { // the sweep exists to prevent. const child = new FakeChild(); child.exitCode = 0; - await terminateChildProcess(child, { timeoutMs: 5_000 }); + await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 5_000 })); expect(child.signals).toEqual([]); }); @@ -85,7 +90,7 @@ describe("terminateChildProcess on an already-exited child", () => { }); vi.useFakeTimers(); try { - const termination = terminateChildProcess(child, { timeoutMs: 300 }); + const termination = Effect.runPromise(terminateChildProcess(child, { timeoutMs: 300 })); await vi.runAllTimersAsync(); await termination; expect(child.signals).toEqual(["SIGTERM"]); @@ -93,4 +98,14 @@ describe("terminateChildProcess on an already-exited child", () => { vi.useRealTimers(); } }); + + it("removes the exit listener when termination is interrupted", async () => { + const child = new FakeChild(); + const fiber = Effect.runFork(terminateChildProcess(child, { timeoutMs: 1_000 })); + await Effect.runPromise(Effect.yieldNow); + + expect(child.listenerCount).toBe(1); + await Effect.runPromise(Fiber.interrupt(fiber)); + expect(child.listenerCount).toBe(0); + }); }); diff --git a/packages/stack/src/version-plan.unit.test.ts b/packages/stack/src/version-plan.unit.test.ts index afc6e400c1..474a52170b 100644 --- a/packages/stack/src/version-plan.unit.test.ts +++ b/packages/stack/src/version-plan.unit.test.ts @@ -16,14 +16,14 @@ describe("planStackVersions", () => { candidateBaseline: { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", + postgrest: "v14.5", + auth: "v2.187.0", }, pinnedBaseline: { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", + postgrest: "v14.5", + auth: "v2.187.0", }, }); }); @@ -49,14 +49,14 @@ describe("planStackVersions", () => { runtimeVersions: { ...DEFAULT_VERSIONS, postgres: "17.4.1.045", - postgrest: "14.5", - auth: "2.170.0", - storage: "1.40.0", + postgrest: "v14.5", + auth: "v2.170.0", + storage: "v1.40.0", }, activeOverrides: [ { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "2.170.0", source: "flag" }, - { service: "storage", version: "1.40.0", source: "local" }, + { service: "auth", version: "v2.170.0", source: "flag" }, + { service: "storage", version: "v1.40.0", source: "local" }, ], }); }); @@ -79,15 +79,15 @@ describe("planStackVersions", () => { { service: "auth", pinnedVersion: "2.188.0-rc.15", - availableVersion: "2.188.1", + availableVersion: "v2.188.1", }, { service: "storage", pinnedVersion: "1.41.8", - availableVersion: "1.43.3", + availableVersion: "v1.43.3", }, ], - updateFingerprint: "auth:2.188.0-rc.15->2.188.1|storage:1.41.8->1.43.3", + updateFingerprint: "auth:2.188.0-rc.15->v2.188.1|storage:1.41.8->v1.43.3", }); }); }); diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index 4828de5064..66854fbc81 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -1,11 +1,11 @@ import { DEFAULT_VERSIONS, SERVICE_NAMES, - dockerImageCandidatesForArtifact, dockerImageForArtifact, - imageTagPrefixForService, + serviceMetadata, } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; +import { Schema } from "effect"; export { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; export type { ServiceName } from "./ServiceName.ts"; @@ -30,28 +30,11 @@ export const PartialVersionManifestSchema = Schema.Struct({ export type PartialVersionManifest = Schema.Schema.Type; -export const IMAGE_TAG_PREFIX: Partial> = Object.fromEntries( - SERVICE_NAMES.flatMap((service) => { - const prefix = imageTagPrefixForService(service); - return prefix === undefined ? [] : [[service, prefix]]; - }), -); - /** * Returns the full Docker image URL for a service. - * - * Uses the same registry resolution as the Go CLI: images are pulled from - * `public.ecr.aws/supabase/` by default (faster than Docker Hub). */ export function dockerImageForService(service: ServiceName, version: string): string { - return dockerImageForArtifact(service, version); -} - -export function dockerImageCandidatesForService( - service: ServiceName, - version: string, -): ReadonlyArray { - return dockerImageCandidatesForArtifact(service, version); + return dockerImageForArtifact(service, normalizeServiceVersion(service, version)); } function assertFullVersions( @@ -70,28 +53,20 @@ export function fullVersionManifest( return versions; } -/** - * Normalizes a version string for a service based on its image tag prefix. - * - * Services with a "v" prefix in IMAGE_TAG_PREFIX (e.g. postgrest, auth) store - * versions without the "v" prefix (it gets prepended at image-pull time). - * Services without a prefix entry but whose DEFAULT_VERSIONS start with "v" - * (e.g. imgproxy, mailpit) store versions with the "v" prefix. - * All other services pass through trimmed. - */ +/** Normalizes a version string to the catalog's canonical stored form. */ export function normalizeServiceVersion(service: ServiceName, version: string): string { - const trimmed = version.trim(); - const prefix = IMAGE_TAG_PREFIX[service]; - - if (prefix === "v") { - return trimmed.replace(/^v/i, ""); - } - - if (prefix === undefined && DEFAULT_VERSIONS[service].startsWith("v")) { - return /^v/i.test(trimmed) ? `v${trimmed.slice(1)}` : `v${trimmed}`; - } - - return trimmed; + const normalized = version.trim(); + const metadata = serviceMetadata(service); + const tagPrefix = metadata.artifact.docker.tagPrefix; + const withoutDockerTagPrefix = + tagPrefix !== undefined && + normalized.slice(0, tagPrefix.length).toLowerCase() === tagPrefix.toLowerCase() + ? normalized.slice(tagPrefix.length) + : normalized; + if (!metadata.defaultVersion.startsWith("v")) return withoutDockerTagPrefix; + return withoutDockerTagPrefix.slice(0, 1).toLowerCase() === "v" + ? `v${withoutDockerTagPrefix.slice(1)}` + : `v${withoutDockerTagPrefix}`; } export function normalizeServiceVersions( @@ -136,4 +111,3 @@ export function diffPinnedAndAvailableVersions( return [{ service, pinnedVersion, availableVersion }]; }); } -import { Schema } from "effect"; diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts index bd5e584c3f..85713b64d0 100644 --- a/packages/stack/src/versions.unit.test.ts +++ b/packages/stack/src/versions.unit.test.ts @@ -6,7 +6,6 @@ import { import { DEFAULT_VERSIONS, diffPinnedAndAvailableVersions, - dockerImageCandidatesForService, dockerImageForService, fillServiceVersionManifest, normalizeServiceVersion, @@ -52,7 +51,7 @@ describe("syncDefaultVersionsSource", () => { 'name: "postgres",\n configKey: "example",\n defaultVersion: "17.0.0.1"', ); expect(updated).toContain( - 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "1.70.0"', + 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "v1.70.0"', ); expect(updated).toContain( 'name: "mailpit",\n configKey: "example",\n defaultVersion: "v1.2.3"', @@ -82,73 +81,74 @@ describe("dockerImageForService", () => { it("returns correct image for postgres", () => { expect(dockerImageForService("postgres", DEFAULT_VERSIONS.postgres)).toBe( - `public.ecr.aws/supabase/postgres:${DEFAULT_VERSIONS.postgres}`, + `ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`, ); }); it("returns correct image for postgrest (with v prefix)", () => { expect(dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest)).toBe( - `public.ecr.aws/supabase/postgrest:v${DEFAULT_VERSIONS.postgrest}`, + `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, ); }); it("returns correct image for auth (with v prefix)", () => { expect(dockerImageForService("auth", DEFAULT_VERSIONS.auth)).toBe( - `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, + `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`, ); }); it("returns correct image for edge-runtime (with v prefix)", () => { expect(dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"])).toBe( - `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, ); }); - it("returns ECR, Docker Hub, and GHCR candidates for Supabase-owned images", () => { - expect(dockerImageCandidatesForService("auth", DEFAULT_VERSIONS.auth)).toEqual([ - `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, - `supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, - `ghcr.io/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, - ]); - }); - - it("does not add fallback registries for third-party images", () => { - expect(dockerImageCandidatesForService("imgproxy", DEFAULT_VERSIONS.imgproxy)).toEqual([ - `darthsim/imgproxy:${DEFAULT_VERSIONS.imgproxy}`, - ]); + it("uses canonical GHCR for every service", () => { + expect(dockerImageForService("imgproxy", DEFAULT_VERSIONS.imgproxy)).toBe( + `ghcr.io/supabase/cli/imgproxy:${DEFAULT_VERSIONS.imgproxy}`, + ); }); it("keeps non-managed services Docker-only", () => { expect(SERVICE_CATALOG.imgproxy).toMatchObject({ runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "darthsim/imgproxy" } }, + artifact: { docker: { repository: "imgproxy" } }, }); expect(SERVICE_CATALOG.mailpit).toMatchObject({ runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "axllent/mailpit" } }, + artifact: { docker: { repository: "mailpit" } }, }); expect(SERVICE_CATALOG.vector).toMatchObject({ runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "timberio/vector" } }, + artifact: { docker: { repository: "vector" } }, }); }); }); describe("normalizeServiceVersion", () => { - it("strips v prefix for services with IMAGE_TAG_PREFIX 'v'", () => { - expect(normalizeServiceVersion("postgrest", "v14.5")).toBe("14.5"); - expect(normalizeServiceVersion("auth", "v2.188.0")).toBe("2.188.0"); - expect(normalizeServiceVersion("edge-runtime", "v1.73.0")).toBe("1.73.0"); + it("preserves frozen leading v tags", () => { + expect(normalizeServiceVersion("postgrest", "v14.5")).toBe("v14.5"); + expect(normalizeServiceVersion("auth", "v2.188.0")).toBe("v2.188.0"); + expect(normalizeServiceVersion("edge-runtime", "v1.73.0")).toBe("v1.73.0"); }); - it("ensures v prefix for services whose defaults start with v", () => { + it("normalizes bare versions for services with v-prefixed catalog releases", () => { expect(normalizeServiceVersion("mailpit", "1.30.2")).toBe("v1.30.2"); expect(normalizeServiceVersion("imgproxy", "3.8.0")).toBe("v3.8.0"); + expect(normalizeServiceVersion("mailpit", "V1.30.2")).toBe("v1.30.2"); }); it("passes through other services unchanged", () => { expect(normalizeServiceVersion("postgres", "17.6.1.090")).toBe("17.6.1.090"); }); + + it("normalizes a prefixed pgmeta override to its catalog tag", () => { + expect(normalizeServiceVersion("pgmeta", "v0.98.0")).toBe("0.98.0"); + expect(normalizeServiceVersion("pgmeta", "V0.98.0")).toBe("0.98.0"); + expect(dockerImageForService("pgmeta", normalizeServiceVersion("pgmeta", "v0.98.0"))).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.98.0", + ); + }); }); describe("fillServiceVersionManifest", () => { diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index 8f81ad820e..f2882266a0 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; +import { DEFAULT_VERSIONS } from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 180_000; @@ -37,7 +38,6 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack = await createStack({ mode: "docker", - startupMode: "lazy", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, analytics: {}, @@ -51,7 +51,15 @@ dockerDescribe("createStack e2e (docker mode)", () => { } const dbPort = parseInt(new URL(stack.dbUrl).port); - await setupTestTable(dbPort); + try { + await setupTestTable(dbPort); + } catch (error) { + const status = await stack.getStatus(); + const logs = await stack.logHistory("postgres"); + throw new Error( + `setupTestTable failed: ${String(error)}\nstatus=${JSON.stringify(status)}\nlogs=${JSON.stringify(logs)}`, + ); + } apiPort = new URL(stack.url).port; supabase = createClient(stack.url, stack.publishableKey); @@ -78,9 +86,11 @@ dockerDescribe("createStack e2e (docker mode)", () => { await Promise.all([stack.startService("postgrest"), stack.startService("auth")]); const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - expect(runningImages).toContain("supabase/postgrest"); - expect(runningImages).toContain("supabase/postgres"); - expect(runningImages).toContain("supabase/gotrue"); + expect(runningImages).toContain( + `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, + ); + expect(runningImages).toContain(`ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`); + expect(runningImages).toContain(`ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`); const [proxyRes, authRes] = await Promise.all([ fetch(`${stack.url}/health`), @@ -104,7 +114,9 @@ dockerDescribe("createStack e2e (docker mode)", () => { const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); const states = await stack.getStatus(); - expect(runningImages).toContain("supabase/edge-runtime"); + expect(runningImages).toContain( + `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, + ); expect(states).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), @@ -133,7 +145,9 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack.getStatus(), ]); - expect(runningImages).toContain("supabase/logflare"); + expect(runningImages).toContain( + `ghcr.io/supabase/cli/analytics:${DEFAULT_VERSIONS.analytics}`, + ); expect(states).toEqual( expect.arrayContaining([expect.objectContaining({ name: "analytics", status: "Healthy" })]), ); @@ -201,4 +215,41 @@ dockerDescribe("createStack e2e (docker mode)", () => { expect(remaining.data).toHaveLength(0); }, ); + + test( + "restarts the Studio graph with its Pgmeta dependency", + { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, + async () => { + const graphDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-graph-")); + let graphStack: StackHandle | undefined; + try { + graphStack = await createStack({ + mode: "docker", + postgres: { dataDir: graphDataDir }, + pgmeta: {}, + studio: {}, + }); + await graphStack.start(); + expect(await graphStack.getServiceStatus("pgmeta")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + expect(await graphStack.getServiceStatus("studio")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + + await graphStack.stop(); + await graphStack.start(); + + expect(await graphStack.getServiceStatus("pgmeta")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + expect(await graphStack.getServiceStatus("studio")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + } finally { + await graphStack?.dispose(); + rmSync(graphDataDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/packages/stack/tests/createStack-native.e2e.test.ts b/packages/stack/tests/createStack-native.e2e.test.ts new file mode 100644 index 0000000000..ed582221d9 --- /dev/null +++ b/packages/stack/tests/createStack-native.e2e.test.ts @@ -0,0 +1,104 @@ +import { createClient } from "@supabase/supabase-js"; +import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createStack, type StackHandle } from "../src/node.ts"; +import { defaultCacheRoot } from "../src/paths.ts"; +import { setupTestTable } from "./helpers/e2e.ts"; + +describe("native PostgREST tracer bullet", () => { + const jwtSecret = "native-e2e-jwt-secret-with-at-least-32-characters"; + let stack: StackHandle; + let dataDir: string; + let cacheParent: string; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), "supabase-native-postgrest-e2e-")); + cacheParent = mkdtempSync(join(tmpdir(), "supabase-native-cache-parent-")); + const cacheRoot = join(cacheParent, "cache root with spaces"); + symlinkSync(defaultCacheRoot(), cacheRoot, "dir"); + stack = await createStack({ + mode: "native", + cacheRoot, + functions: false, + edgeRuntime: false, + auth: false, + jwtSecret, + postgres: { dataDir }, + }); + await stack.start(); + await setupTestTable(parseInt(new URL(stack.dbUrl).port)); + }, 45_000); + + afterAll(async () => { + await stack?.dispose(); + rmSync(dataDir, { recursive: true, force: true }); + rmSync(cacheParent, { recursive: true, force: true }); + }, 30_000); + + test("serves a CRUD request through the native PostgREST resource", async () => { + const client = createClient(stack.url, stack.publishableKey); + const inserted = await client + .from("todos") + .insert({ title: "native tracer bullet" }) + .select() + .single(); + + expect(inserted.error).toBeNull(); + expect(inserted.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); + + const deleted = await client.from("todos").delete().eq("title", "native tracer bullet"); + expect(deleted.error).toBeNull(); + }, 30_000); + + test("persists JWT settings in the native Postgres database", async () => { + const sql = new Bun.SQL(stack.dbUrl); + try { + const rows = await sql.unsafe<{ jwt_secret: string; jwt_exp: string }[]>(` + SELECT + current_setting('app.settings.jwt_secret') AS jwt_secret, + current_setting('app.settings.jwt_exp') AS jwt_exp; + `); + expect(rows[0]).toEqual({ jwt_secret: jwtSecret, jwt_exp: "3600" }); + } finally { + await sql.close(); + } + }, 30_000); + + test("repairs incomplete bundled initialization on restart", async () => { + const adminUrl = new URL(stack.dbUrl); + adminUrl.username = "supabase_admin"; + const sql = new Bun.SQL(adminUrl.toString()); + try { + await sql.unsafe(` + DELETE FROM supabase_migrations.cli_init WHERE phase = 'complete'; + ALTER ROLE authenticator RESET session_preload_libraries; + `); + } finally { + await sql.close(); + } + + await stack.stop(); + await stack.start(); + + const check = new Bun.SQL(adminUrl.toString()); + try { + const rows = await check.unsafe<{ configured: boolean }[]>(` + SELECT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'authenticator' + AND EXISTS ( + SELECT 1 + FROM unnest(coalesce(rolconfig, ARRAY[]::text[])) setting + WHERE setting LIKE 'session_preload_libraries=supautils%' + ) + ) AS configured; + `); + expect(rows[0]?.configured).toBe(true); + } finally { + await check.close(); + } + }, 30_000); +}); diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 6511c750ad..3afdaa7a57 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -3,10 +3,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; import { createStack, type ResolvedFunctionsBundle, type StackHandle } from "../src/node.ts"; import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; -const STACK_E2E_TEST_TIMEOUT_MS = 5_000; +const AUTH_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("auth") * 1000; describe("createStack e2e", () => { let stack: StackHandle; @@ -47,24 +48,6 @@ describe("createStack e2e", () => { } catch {} }, 30_000); - test( - "serves health endpoints through the local gateway", - { timeout: STACK_E2E_TEST_TIMEOUT_MS }, - async () => { - const [proxyRes, authRes] = await Promise.all([ - fetch(`${stack.url}/health`), - fetch(`${stack.url}/auth/v1/health`), - ]); - - expect(proxyRes.status).toBe(200); - expect(await proxyRes.text()).toBe("OK"); - expect(authRes.status).toBe(200); - expect(await authRes.json()).toEqual( - expect.objectContaining({ description: expect.any(String) }), - ); - }, - ); - test( "serves detected Edge Functions through the local gateway", { timeout: 30_000 }, @@ -72,10 +55,8 @@ describe("createStack e2e", () => { // "Healthy" only means the edge-runtime control plane answered its health // probe; the first request to a function still lazily cold-boots a user // worker, so wait for the function to actually become servable. - const [states, functionsRes] = await Promise.all([ - stack.getStatus(), - fetchFunctionWhenReady(`${stack.url}/functions/v1/hello`), - ]); + const functionsRes = await fetchFunctionWhenReady(`${stack.url}/functions/v1/hello`); + const states = await stack.getStatus(); expect(states).toEqual( expect.arrayContaining([ @@ -99,7 +80,7 @@ describe("createStack e2e", () => { test( "supports the auth signup and session golden path", - { timeout: STACK_E2E_TEST_TIMEOUT_MS }, + { timeout: AUTH_COLD_START_TEST_TIMEOUT_MS }, async () => { const testEmail = `test-${Date.now()}@example.com`; const testPassword = "test-password-123"; @@ -126,38 +107,34 @@ describe("createStack e2e", () => { }, ); - test( - "supports a full PostgREST CRUD golden path", - { timeout: STACK_E2E_TEST_TIMEOUT_MS }, - async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }, - ); + test("supports a full PostgREST CRUD golden path", { timeout: 30_000 }, async () => { + const seeded = await supabase.from("todos").select("*").order("id"); + expect(seeded.error).toBeNull(); + expect(seeded.data).toHaveLength(2); + + const inserted = await supabase + .from("todos") + .insert({ title: "E2E test todo" }) + .select() + .single(); + expect(inserted.error).toBeNull(); + expect(inserted.data?.title).toBe("E2E test todo"); + + const updated = await supabase + .from("todos") + .update({ completed: true }) + .eq("title", "E2E test todo") + .select() + .single(); + expect(updated.error).toBeNull(); + expect(updated.data?.completed).toBe(true); + + const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); + expect(deleted.error).toBeNull(); + + const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); + expect(remaining.data).toHaveLength(0); + }); }); function writeFunction(projectDir: string, slug: string, body: string) { diff --git a/packages/stack/tests/global-setup.ts b/packages/stack/tests/global-setup.ts index f396e68f24..108d681c57 100644 --- a/packages/stack/tests/global-setup.ts +++ b/packages/stack/tests/global-setup.ts @@ -1,5 +1,5 @@ import { warmStackE2eDependencies } from "./helpers/warmup.ts"; export async function setup(): Promise { - await warmStackE2eDependencies(); + await warmStackE2eDependencies({ failOnError: true }); } diff --git a/packages/stack/tests/helpers/e2e.ts b/packages/stack/tests/helpers/e2e.ts index d22aefbb48..c62180b672 100644 --- a/packages/stack/tests/helpers/e2e.ts +++ b/packages/stack/tests/helpers/e2e.ts @@ -50,32 +50,33 @@ export async function fetchFunctionWhenReady( */ export async function setupTestTable(dbPort: number): Promise { const sql = new Bun.SQL(`postgresql://supabase_admin:postgres@127.0.0.1:${dbPort}/postgres`); + try { + await sql.unsafe(` + CREATE TABLE IF NOT EXISTS public.todos ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT false + ); - await sql.unsafe(` - CREATE TABLE IF NOT EXISTS public.todos ( - id SERIAL PRIMARY KEY, - title TEXT NOT NULL, - completed BOOLEAN NOT NULL DEFAULT false - ); - - ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; - - DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'todos' AND policyname = 'allow_all') THEN - CREATE POLICY allow_all ON public.todos FOR ALL USING (true) WITH CHECK (true); - END IF; - END $$; + ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; - GRANT ALL ON public.todos TO anon, authenticated, service_role; - GRANT USAGE, SELECT ON SEQUENCE public.todos_id_seq TO anon, authenticated, service_role; + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'todos' AND policyname = 'allow_all') THEN + CREATE POLICY allow_all ON public.todos FOR ALL USING (true) WITH CHECK (true); + END IF; + END $$; - INSERT INTO public.todos (title, completed) VALUES - ('Learn Supabase', true), - ('Build an app', false); - `); + GRANT ALL ON public.todos TO anon, authenticated, service_role; + GRANT USAGE, SELECT ON SEQUENCE public.todos_id_seq TO anon, authenticated, service_role; - // PostgREST caches schema metadata, so tell it to reload after creating test tables. - await sql.unsafe(`NOTIFY pgrst, 'reload schema';`); + INSERT INTO public.todos (title, completed) VALUES + ('Learn Supabase', true), + ('Build an app', false); + `); - sql.close(); + // PostgREST caches schema metadata, so tell it to reload after creating test tables. + await sql.unsafe(`NOTIFY pgrst, 'reload schema';`); + } finally { + await sql.close(); + } } diff --git a/packages/stack/tests/helpers/file-watch.ts b/packages/stack/tests/helpers/file-watch.ts new file mode 100644 index 0000000000..783de7cf8c --- /dev/null +++ b/packages/stack/tests/helpers/file-watch.ts @@ -0,0 +1,37 @@ +import { watch, type FSWatcher } from "node:fs"; + +/** + * Watches a directory and re-arms when the runtime reports a transient ENOENT + * while an entry disappears during a scan. Returns a close function. + */ +export const watchDirectoryWithRetry = ( + directory: string, + onEvent: () => void, + onError: (cause: unknown) => void, +): (() => void) => { + let watcher: FSWatcher | undefined; + let closed = false; + const arm = () => { + if (closed) return; + try { + watcher = watch(directory, onEvent); + watcher.once("error", (cause) => { + watcher?.close(); + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { + arm(); + onEvent(); + return; + } + onError(cause); + }); + } catch (cause) { + closed = true; + onError(cause); + } + }; + arm(); + return () => { + closed = true; + watcher?.close(); + }; +}; diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts index aa1b89b8c6..f1c5973ac2 100644 --- a/packages/stack/tests/helpers/managed-manager.ts +++ b/packages/stack/tests/helpers/managed-manager.ts @@ -1,4 +1,5 @@ -import { Effect, Stream } from "effect"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Effect, Predicate, Stream } from "effect"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; @@ -6,10 +7,11 @@ import { join } from "node:path"; import type { ManagedStackManagerShape, ManagedStackStartResult, + StartStackRequest, } from "../../src/managed/manager.ts"; import { managedStackManagerLayer } from "../../src/managed/manager.ts"; import type { ManagedPortIntentDocument } from "../../src/managed/model.ts"; -import { acquireControl } from "../../src/managed/control.ts"; +import { acquireControl, isControlOwnership } from "../../src/managed/control.ts"; import { deriveStackId, ensureEnvironment } from "../../src/managed/environment.ts"; import { reservePortSet } from "../../src/PortAllocator.ts"; import type { Stack } from "../../src/Stack.ts"; @@ -105,7 +107,7 @@ export const freePorts = ( field, selection: { kind: "automatic" as const }, })), - ); + ).pipe(Effect.provide(NodeFileSystem.layer)); const ports = FREE_PORT_FIELDS.slice(0, count).flatMap((field) => { const port = lease.ports[field]; return port === undefined ? [] : [port]; @@ -151,7 +153,7 @@ export const acquireWorkspaceControl = (base: string, prefix = "workspace") => const acquired = yield* acquireControl({ stackId }).pipe( Effect.map((ownership) => ({ ownership })), Effect.catch((error) => - error._tag === "ControlAddressConflictError" && Date.now() < deadline + Predicate.isTagged(error, "ControlAddressConflictError") && Date.now() < deadline ? Effect.succeed(undefined) : Effect.fail(error), ), @@ -173,15 +175,27 @@ export const startWithOwner = ( const environment = yield* ensureEnvironment(workspacePath); const stackId = deriveStackId(environment.identity, stackName); const ownership = yield* acquireControl({ stackId }); - if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); return yield* manager.startStack({ workspacePath, stackName, portDocument, ownership, lifecycle, + launch: { mode: "native", versions: {} }, }); }); +export const startManagedStack = ( + manager: ManagedStackManagerShape, + request: Omit & { + readonly launch?: StartStackRequest["launch"]; + }, +) => + manager.startStack({ + ...request, + launch: request.launch ?? { mode: "native", versions: {} }, + }); + export const releaseLease = (result: ManagedStackStartResult): Effect.Effect => result.lease.releaseAll; diff --git a/packages/stack/tests/helpers/mocks.ts b/packages/stack/tests/helpers/mocks.ts index 6017124333..efce61a07f 100644 --- a/packages/stack/tests/helpers/mocks.ts +++ b/packages/stack/tests/helpers/mocks.ts @@ -14,6 +14,8 @@ export function mockBinaryResolver( downloadDelayMs?: number; downloadDelaysMs?: Partial>; failServices?: string[]; + failOnceServices?: string[]; + beforeResolve?: (spec: BinarySpec) => Effect.Effect; } = {}, ) { const resolved: Array<{ service: string; version: string }> = []; @@ -23,9 +25,10 @@ export function mockBinaryResolver( auth: `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`, "edge-runtime": `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`, }; + const failOnceServices = new Set(opts.failOnceServices ?? []); const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => Effect.gen(function* () { - if (opts.failServices?.includes(spec.service)) { + if (opts.failServices?.includes(spec.service) || failOnceServices.delete(spec.service)) { return yield* new BinaryNotFoundError({ service: spec.service, platform: "darwin-arm64", @@ -42,6 +45,7 @@ export function mockBinaryResolver( const downloaded = opts.downloadedServices?.includes(spec.service) ?? false; if (downloaded) { yield* options?.onDownloadStart ?? Effect.void; + yield* opts.beforeResolve?.(spec) ?? Effect.void; const delayMs = opts.downloadDelaysMs?.[spec.service] ?? opts.downloadDelayMs ?? 0; if (delayMs > 0) { yield* Effect.sleep(`${delayMs} millis`); @@ -52,6 +56,14 @@ export function mockBinaryResolver( return { layer: Layer.succeed(BinaryResolver, { + plan: (spec) => { + const path = binaries[spec.service]; + return path + ? Effect.succeed(path) + : Effect.fail( + new BinaryNotFoundError({ service: spec.service, platform: "darwin-arm64" }), + ); + }, resolveWithMetadata, resolve: (spec) => Effect.map(resolveWithMetadata(spec), ({ path }) => path), }), diff --git a/packages/stack/tests/helpers/port-lease-child.ts b/packages/stack/tests/helpers/port-lease-child.ts new file mode 100644 index 0000000000..b5b220a1c0 --- /dev/null +++ b/packages/stack/tests/helpers/port-lease-child.ts @@ -0,0 +1,18 @@ +import { NodeFileSystem } from "@effect/platform-node"; +import { Effect } from "effect"; +import { reservePortSet } from "../../src/PortAllocator.ts"; + +const lease = await Effect.runPromise( + reservePortSet([ + { field: "apiPort", selection: { kind: "automatic" } }, + { field: "dbPort", selection: { kind: "automatic" } }, + ]).pipe(Effect.provide(NodeFileSystem.layer)), +); + +process.stdout.write(`${JSON.stringify(lease.ports)}\n`); + +for await (const _chunk of process.stdin) { + break; +} + +await Effect.runPromise(lease.releaseAll); diff --git a/packages/stack/tests/helpers/spawn-stack.ts b/packages/stack/tests/helpers/spawn-stack.ts deleted file mode 100644 index 518d190cba..0000000000 --- a/packages/stack/tests/helpers/spawn-stack.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { resolve } from "node:path"; -import { terminateChildProcess } from "../../src/terminateChild.ts"; - -const STANDALONE_SCRIPT = resolve(import.meta.dirname, "standalone-stack.ts"); -const DEFAULT_READINESS_TIMEOUT_MS = 60_000; -const OUTPUT_TAIL_CHARS = 2_000; - -export interface SpawnedStackInfo { - readonly url: string; - readonly dbUrl: string; - readonly process: ChildProcess; -} - -export interface SpawnStandaloneStackOptions { - /** Overridable for unit tests only; the e2e suite always runs the real script. */ - readonly command?: readonly [string, ...string[]]; - readonly readinessTimeoutMs?: number; - /** - * Fired the moment the child exists, before readiness. Callers register the - * handle here so teardown can terminate every spawned child even when the - * readiness promise never resolved — a `Promise.all` that dies on one stack - * must not orphan its siblings. - */ - readonly onSpawn?: (child: ChildProcess) => void; -} - -/** - * Spawns one standalone stack subprocess and resolves when it reports - * readiness (a single JSON line on stdout). Unlike a bare spawn-and-parse, - * every way the child can fail settles the promise with the evidence attached: - * - * - exit before readiness — ANY code, including 0 — rejects with the code and - * the child's stderr, so a stack that dies cleanly during bring-up cannot - * turn into an opaque hook timeout with its error discarded; - * - readiness not reported within `readinessTimeoutMs` rejects with the - * stdout/stderr collected so far and terminates the child, so a bring-up - * that wedges (e.g. a port race) fails fast and names the last thing the - * stack said instead of burning the whole hook budget. - */ -export function spawnStandaloneStack( - opts: SpawnStandaloneStackOptions = {}, -): Promise { - const [command, ...args] = opts.command ?? [ - "bun", - "run", - STANDALONE_SCRIPT, - "--parent-pid", - String(process.pid), - ]; - const readinessTimeoutMs = opts.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS; - - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); - opts.onSpawn?.(child); - - let stdout = ""; - let stderr = ""; - let settled = false; - - const settle = (outcome: { info?: SpawnedStackInfo; error?: Error }) => { - if (settled) return; - settled = true; - clearTimeout(readinessTimer); - if (outcome.info !== undefined) resolvePromise(outcome.info); - else rejectPromise(outcome.error); - }; - - const outputTail = () => - `stdout: ${stdout.slice(-OUTPUT_TAIL_CHARS) || "(none)"}\nstderr: ${ - stderr.slice(-OUTPUT_TAIL_CHARS) || "(none)" - }`; - - const readinessTimer = setTimeout(() => { - settle({ - error: new Error( - `Stack did not report readiness within ${readinessTimeoutMs}ms\n${outputTail()}`, - ), - }); - // Reclaim the unusable child; the 30s window matches the suite's own - // sweep so SIGKILL doesn't cut a wedged stack's dispose short. - void terminateChildProcess(child, { timeoutMs: 30_000 }); - }, readinessTimeoutMs); - - child.stdout!.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - const newline = stdout.indexOf("\n"); - if (newline !== -1) { - try { - const info = JSON.parse(stdout.slice(0, newline)); - settle({ info: { url: info.url, dbUrl: info.dbUrl, process: child } }); - } catch { - settle({ error: new Error(`Failed to parse stack info: ${stdout.slice(0, newline)}`) }); - } - } - }); - - child.stderr!.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - child.on("error", (err) => settle({ error: err })); - // Any exit before readiness is a failure — including a clean 0. `close` - // rather than `exit`: it waits for the stdio pipes to drain, so the tails - // below always carry whatever the child managed to say. - child.on("close", (code) => { - settle({ - error: new Error( - `Stack process exited with code ${code} before readiness\n${outputTail()}`, - ), - }); - }); - }); -} diff --git a/packages/stack/tests/helpers/spawn-stack.unit.test.ts b/packages/stack/tests/helpers/spawn-stack.unit.test.ts deleted file mode 100644 index bff1bbc7cb..0000000000 --- a/packages/stack/tests/helpers/spawn-stack.unit.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { type ChildProcess } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, describe, expect, test } from "vitest"; -import { terminateChildProcess } from "../../src/terminateChild.ts"; -import { spawnStandaloneStack } from "./spawn-stack.ts"; - -const dir = mkdtempSync(join(tmpdir(), "spawn-stack-unit-")); -const children: ChildProcess[] = []; - -afterAll(() => { - for (const child of children) { - try { - child.kill("SIGKILL"); - } catch {} - } - rmSync(dir, { recursive: true, force: true }); -}); - -function stub(name: string, source: string): readonly [string, ...string[]] { - const path = join(dir, name); - writeFileSync(path, source); - return ["bun", "run", path]; -} - -const track = (child: ChildProcess) => children.push(child); - -describe("spawnStandaloneStack", () => { - test("resolves with the reported url/dbUrl and a live process handle", async () => { - const command = stub( - "ok.ts", - `console.log(JSON.stringify({ url: "http://127.0.0.1:59991", dbUrl: "postgresql://127.0.0.1:59992/x" })); - setInterval(() => {}, 60_000);`, - ); - const info = await spawnStandaloneStack({ command, onSpawn: track }); - expect(info.url).toBe("http://127.0.0.1:59991"); - expect(info.dbUrl).toBe("postgresql://127.0.0.1:59992/x"); - expect(info.process.exitCode).toBeNull(); - }); - - test("rejects with the exit code and stderr when the child dies cleanly before readiness", async () => { - // The pre-fix harness only rejected on a NON-zero exit, so this exact - // child left the promise pending until the 90s hook timeout, with the - // stderr below discarded — the opaque paired-timeout CI failure. - const command = stub( - "silent-exit0.ts", - `process.stderr.write("boot: port 54322 already bound, giving up\\n"); - process.exit(0);`, - ); - await expect(spawnStandaloneStack({ command, onSpawn: track })).rejects.toThrow( - /exited with code 0 before readiness[\s\S]*port 54322 already bound/, - ); - }); - - test("rejects on readiness timeout and reclaims the child", async () => { - const command = stub( - "hang.ts", - `process.stderr.write("boot: waiting for postgres socket...\\n"); - setInterval(() => {}, 60_000);`, - ); - const spawned: ChildProcess[] = []; - let exited: - | Promise<{ - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - }> - | undefined; - await expect( - spawnStandaloneStack({ - command, - readinessTimeoutMs: 1_500, - onSpawn: (child) => { - track(child); - spawned.push(child); - exited = new Promise((resolve) => - child.once("exit", (code, signal) => resolve({ code, signal })), - ); - }, - }), - ).rejects.toThrow(/did not report readiness within 1500ms/); - // The helper terminates its own unusable child rather than leaving an - // interval-driven zombie for suite teardown to hunt. - const exit = await exited; - expect(exit).toEqual({ code: null, signal: "SIGTERM" }); - expect(spawned[0]?.killed).toBe(true); - }); - - test("rejects on an unparseable readiness line", async () => { - const command = stub("garbage.ts", `console.log("not json"); setInterval(() => {}, 60_000);`); - await expect(spawnStandaloneStack({ command, onSpawn: track })).rejects.toThrow( - /Failed to parse stack info: not json/, - ); - }); - - test("registers every child via onSpawn before readiness, so a failed sibling cannot orphan a healthy one", async () => { - const okCommand = stub( - "ok-sibling.ts", - `console.log(JSON.stringify({ url: "http://127.0.0.1:59993", dbUrl: "postgresql://127.0.0.1:59994/x" })); - setInterval(() => {}, 60_000);`, - ); - const badCommand = stub("bad-sibling.ts", `process.exit(0);`); - - const registered: ChildProcess[] = []; - const results = await Promise.allSettled([ - spawnStandaloneStack({ onSpawn: (c) => registered.push(c), command: okCommand }), - spawnStandaloneStack({ onSpawn: (c) => registered.push(c), command: badCommand }), - ]); - children.push(...registered); - - expect(registered).toHaveLength(2); - expect(results.map((r) => r.status).sort()).toEqual(["fulfilled", "rejected"]); - // The healthy sibling's handle is reachable through the registry even - // though Promise.all-style consumption would have discarded its value. - const healthy = registered.find((c) => c.exitCode === null); - expect(healthy).toBeDefined(); - }); - - test("teardown sweep over a dead child is a no-op", async () => { - // The incident replay: one sibling died before readiness, teardown then - // sweeps every registered child with a 30s timeout. Before the - // already-exited guard in terminateChildProcess this call burned 60s - // doing nothing — reproducing the afterAll hook timeout it was meant to - // prevent. - const command = stub("dead-sweep.ts", `process.exit(0);`); - const registered: ChildProcess[] = []; - await spawnStandaloneStack({ command, onSpawn: (c) => registered.push(c) }).catch(() => {}); - expect(registered[0]?.exitCode).toBe(0); - await terminateChildProcess(registered[0]!, { timeoutMs: 30_000 }); - expect(registered[0]?.exitCode).toBe(0); - }); -}); diff --git a/packages/stack/tests/helpers/stack-ports.ts b/packages/stack/tests/helpers/stack-ports.ts index d76223e9e7..06fe40be4f 100644 --- a/packages/stack/tests/helpers/stack-ports.ts +++ b/packages/stack/tests/helpers/stack-ports.ts @@ -1,3 +1,4 @@ +import { NodeFileSystem } from "@effect/platform-node"; import { Effect } from "effect"; import { createStack, type StackHandle } from "../../src/node.ts"; import { reservePortSet } from "../../src/PortAllocator.ts"; @@ -37,7 +38,7 @@ const reserveEphemeralStackPorts = async (): Promise => return { apiPort, dbPort }; }), ), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ); /** diff --git a/packages/stack/tests/helpers/standalone-stack.ts b/packages/stack/tests/helpers/standalone-stack.ts deleted file mode 100644 index ccc6dfb646..0000000000 --- a/packages/stack/tests/helpers/standalone-stack.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createStack } from "../../src/node.ts"; - -// Registered before any bring-up work: the spawning harness SIGTERMs a stack -// that misses its readiness deadline, and without these the default signal -// disposition kills the process mid-start with temp dirs and containers left -// behind for the leak check to trip over. A pre-readiness signal is remembered -// and honored at the next await boundary via a dispose-then-exit. -let earlyShutdownRequested = false; -let signalEarlyShutdown = () => { - earlyShutdownRequested = true; -}; -const earlyShutdown = new Promise<"early-shutdown">((resolveSignal) => { - signalEarlyShutdown = () => { - earlyShutdownRequested = true; - resolveSignal("early-shutdown"); - }; -}); -const onEarlySignal = () => signalEarlyShutdown(); -process.once("SIGINT", onEarlySignal); -process.once("SIGTERM", onEarlySignal); - -const parentPid = readParentPid(process.argv.slice(2)); -const stack = await createStack(); -if (earlyShutdownRequested) { - await stack.dispose(); - process.exit(0); -} -// Raced rather than awaited directly: a signal during a HUNG start() must -// still dispose whatever was already created — a flag alone can't run until -// the await returns, which is exactly when it never will. -const starting = stack.start().then( - () => "started" as const, - (error) => { - if (!earlyShutdownRequested) throw error; - return "start-failed" as const; - }, -); -if ((await Promise.race([starting, earlyShutdown])) !== "started") { - await stack.dispose(); - process.exit(0); -} -if (earlyShutdownRequested) { - await stack.dispose(); - process.exit(0); -} -process.off("SIGINT", onEarlySignal); -process.off("SIGTERM", onEarlySignal); - -// Signal readiness to parent process -console.log(JSON.stringify({ url: stack.url, dbUrl: stack.dbUrl })); - -await waitForShutdown(parentPid); -await stack.dispose(); -process.exit(0); - -function waitForShutdown(parentPid: number | undefined): Promise { - return new Promise((resolve) => { - const onShutdown = () => { - cleanup(); - resolve(); - }; - - const onParentExit = () => { - onShutdown(); - }; - - const parentWatchdog = - parentPid == null - ? undefined - : setInterval(() => { - if (!isProcessAlive(parentPid)) { - onParentExit(); - } - }, 250); - - parentWatchdog?.unref(); - - const cleanup = () => { - process.off("SIGINT", onShutdown); - process.off("SIGTERM", onShutdown); - process.off("disconnect", onParentExit); - if (parentWatchdog != null) { - clearInterval(parentWatchdog); - } - }; - - process.once("SIGINT", onShutdown); - process.once("SIGTERM", onShutdown); - process.once("disconnect", onParentExit); - }); -} - -function readParentPid(argv: ReadonlyArray): number | undefined { - const flagIndex = argv.indexOf("--parent-pid"); - const rawValue = flagIndex === -1 ? undefined : argv[flagIndex + 1]; - if (rawValue == null) { - return undefined; - } - - const value = Number.parseInt(rawValue, 10); - return Number.isInteger(value) && value > 0 ? value : undefined; -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 3883a4d19a..684b646aff 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { BunFileSystem, BunServices } from "@effect/platform-bun"; import { Effect, Layer, Stream, Duration } from "effect"; import { createServer, type Server } from "node:net"; -import { existsSync, type FSWatcher, watch, writeFileSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { runSupervisor, @@ -10,6 +10,7 @@ import { type SupervisorPlatform, } from "../../src/supervisor.ts"; import { Stack } from "../../src/Stack.ts"; +import { validateResolvedConfig } from "../../src/StackBuilder.ts"; import { gitConfigStoreLayer } from "../../src/managed/git.ts"; import { ManagedStackManager, managedStackManagerLayer } from "../../src/managed/manager.ts"; import { @@ -23,14 +24,42 @@ import { import { PORT_FIELDS } from "../../src/PortCatalog.ts"; import type { PortLease } from "../../src/PortAllocator.ts"; import type { ResolvedDaemonConfig } from "../../src/StackConfig.ts"; +import { watchDirectoryWithRetry } from "./file-watch.ts"; type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; +const FILE_WAIT_TIMEOUT = "30 seconds"; const waitForFile = (path: string): Effect.Effect => - Effect.suspend(() => - existsSync(path) - ? Effect.void - : Effect.sleep("10 millis").pipe(Effect.andThen(waitForFile(path))), + Effect.callback((resume) => { + if (existsSync(path)) { + resume(Effect.void); + return Effect.void; + } + let settled = false; + let stopWatching: (() => void) | undefined; + const cleanup = () => { + stopWatching?.(); + stopWatching = undefined; + }; + const settle = (result: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + resume(result); + }; + const check = () => { + if (existsSync(path)) settle(Effect.void); + }; + stopWatching = watchDirectoryWithRetry(dirname(path), check, (cause) => + settle(Effect.die(cause)), + ); + check(); + return Effect.sync(cleanup); + }).pipe( + Effect.timeout(FILE_WAIT_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.die(new Error(`timed out waiting for file ${path} after ${FILE_WAIT_TIMEOUT}`)), + ), ); const testMode = (): TestMode => { @@ -87,7 +116,18 @@ const testStackLayer = (config: ResolvedDaemonConfig, mode: TestMode): Layer.Lay return Layer.succeed(Stack, { getInfo: () => Effect.succeed(info), start: () => Effect.void, - stop: () => (mode === "hold-stop" ? waitForStopRelease() : Effect.void), + stop: () => + mode === "hold-stop" + ? Effect.gen(function* () { + const stageFile = process.env["SUPABASE_STACK_TEST_STOP_BEGAN_FILE"]; + if (stageFile === undefined) { + yield* sendTestStage("stop-began").pipe(Effect.orDie); + } else { + yield* Effect.sync(() => writeFileSync(stageFile, "began")); + } + yield* waitForStopRelease(); + }) + : Effect.void, dispose: () => Effect.void, startService: () => Effect.void, stopService: () => Effect.void, @@ -116,6 +156,7 @@ const testRuntime = ({ }): Effect.Effect, unknown, import("effect").Scope.Scope> => { const mode = testMode(); return Effect.gen(function* () { + yield* validateResolvedConfig(config); if (mode === "hold-start") yield* Effect.never; const servers: Array = []; if (mode !== "hold-reservations") { @@ -142,10 +183,10 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { if (readyFile === undefined || releaseFile === undefined) return Effect.void; return Effect.callback((resume) => { let settled = false; - let watcher: FSWatcher | undefined; + let stopWatching: (() => void) | undefined; const cleanup = () => { - watcher?.close(); - watcher = undefined; + stopWatching?.(); + stopWatching = undefined; }; const settle = (result: Effect.Effect) => { if (settled) return; @@ -156,23 +197,10 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { const resolveIfReleased = () => { if (existsSync(releaseFile)) settle(Effect.void); }; - // Re-arm on ENOENT watcher errors: the runtime's directory watcher can - // report ENOENT when a watched entry vanishes mid-scan. - const arm = () => { - if (settled) return; - watcher = watch(dirname(releaseFile), () => resolveIfReleased()); - watcher.once("error", (cause) => { - watcher?.close(); - if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { - arm(); - resolveIfReleased(); - return; - } - settle(Effect.die(cause)); - }); - }; try { - arm(); + stopWatching = watchDirectoryWithRetry(dirname(releaseFile), resolveIfReleased, (cause) => + settle(Effect.die(cause)), + ); writeFileSync(readyFile, "ready"); resolveIfReleased(); } catch (cause) { @@ -182,14 +210,16 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { }); }; -const sendTestStage = (): Effect.Effect => +const sendTestStage = ( + stage: "attached-before-ready" | "managed-started" | "stop-began", +): Effect.Effect => Effect.callback((resume) => { if (process.send === undefined || !process.connected) { resume(Effect.void); return Effect.void; } try { - process.send({ type: "test-stage", stage: "attached-before-ready" }, (error) => + process.send({ type: "test-stage", stage }, (error) => resume( error === null ? Effect.void @@ -206,7 +236,10 @@ const sendTestStage = (): Effect.Effect => ); } return Effect.void; - }).pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); + }); + +const sendAttachedBeforeReadyStage = (): Effect.Effect => + sendTestStage("attached-before-ready").pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); const resolutionTimeout = (): Duration.Input => { const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); @@ -233,17 +266,24 @@ const managerLayer = (stateRoot: string, platform: "node" | "bun") => (base) => { const readyFile = process.env["SUPABASE_STACK_TEST_ENSURE_READY_FILE"]; const releaseFile = process.env["SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE"]; - if (readyFile === undefined || releaseFile === undefined) return base; return Layer.effect( ManagedStackManager, ManagedStackManager.pipe( Effect.map((manager) => ({ ...manager, - ensureWorkspace: (workspacePath: string) => - Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( - Effect.andThen(waitForFile(releaseFile)), - Effect.andThen(manager.ensureWorkspace(workspacePath)), - ), + startStack: (input: Parameters[0]) => + manager + .startStack(input) + .pipe(Effect.tap(() => sendTestStage("managed-started").pipe(Effect.orDie))), + ...(readyFile === undefined || releaseFile === undefined + ? {} + : { + ensureWorkspace: (workspacePath: string) => + Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( + Effect.andThen(waitForFile(releaseFile)), + Effect.andThen(manager.ensureWorkspace(workspacePath)), + ), + }), })), ), ).pipe(Layer.provide(base)); @@ -258,7 +298,7 @@ export const runTestSupervisor = (): void => { platformFactory: platformKind === "bun" ? bunPlatformFactory : nodePlatformFactory, managerLayer: (stateRoot) => managerLayer(stateRoot, platformKind), runtimeLayer: testRuntime, - onAttachedBeforeReady: sendTestStage, + onAttachedBeforeReady: sendAttachedBeforeReadyStage, resolutionTimeout: resolutionTimeout(), }; const program = runSupervisor(supervisorPlatform).pipe( diff --git a/packages/stack/tests/helpers/warmup.ts b/packages/stack/tests/helpers/warmup.ts index 7660bdc578..9db0bf2114 100644 --- a/packages/stack/tests/helpers/warmup.ts +++ b/packages/stack/tests/helpers/warmup.ts @@ -29,18 +29,19 @@ export async function warmStackE2eDependencies( const shouldFailOnError = options.failOnError ?? false; const dockerAvailable = (options.hasDockerDaemon ?? hasDockerDaemon)(); - try { - const warmups = [prefetchDeps()]; - if (dockerAvailable) { - warmups.push(prefetchDeps({ mode: "docker" })); - } - await Promise.all(warmups); - } catch (error) { - logger.warn( - `[stack-e2e] Warmup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - if (shouldFailOnError) { - throw error; + const modes: PrefetchOptions[] = [{ mode: "native" }]; + if (dockerAvailable) modes.push({ mode: "docker" }); + + for (const mode of modes) { + try { + await prefetchDeps(mode); + } catch (error) { + logger.warn( + `[stack-e2e] Warmup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + if (shouldFailOnError) { + throw error; + } } } } diff --git a/packages/stack/tests/helpers/warmup.unit.test.ts b/packages/stack/tests/helpers/warmup.unit.test.ts index 5e805ffb0f..16ab704f69 100644 --- a/packages/stack/tests/helpers/warmup.unit.test.ts +++ b/packages/stack/tests/helpers/warmup.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import type { PrefetchOptions, PrefetchResult } from "../../src/node.ts"; import { warmStackE2eDependencies } from "./warmup.ts"; @@ -29,32 +29,20 @@ function makeResult(type: "binary" | "docker"): PrefetchResult { } describe("stack e2e warmup", () => { - test("runs auto prefetch and docker image warmup when Docker is available", async () => { + test("warms native and Docker resources when Docker is available", async () => { const calls: Array = []; const { logger } = makeLogger(); - let finishAutoPrefetch: (() => void) | undefined; - const warmup = warmStackE2eDependencies({ + await warmStackE2eDependencies({ logger, hasDockerDaemon: () => true, prefetch: async (options?: PrefetchOptions) => { calls.push(options); - if (options === undefined) { - await new Promise((resolve) => { - finishAutoPrefetch = resolve; - }); - } return options?.mode === "docker" ? makeResult("docker") : makeResult("binary"); }, }); - await vi.waitFor(() => { - expect(calls).toEqual([undefined, { mode: "docker" }]); - }); - finishAutoPrefetch?.(); - await warmup; - - expect(calls).toEqual([undefined, { mode: "docker" }]); + expect(calls).toEqual([{ mode: "native" }, { mode: "docker" }]); }); test("skips docker image warmup when Docker is unavailable", async () => { @@ -70,7 +58,7 @@ describe("stack e2e warmup", () => { }, }); - expect(calls).toEqual([undefined]); + expect(calls).toEqual([{ mode: "native" }]); }); test("can fail fast when warmup is required", async () => { @@ -89,6 +77,24 @@ describe("stack e2e warmup", () => { expect(warn.some((message) => message.includes("Warmup failed"))).toBe(true); }); + test("continues to the Docker warmup after a best-effort native failure", async () => { + const calls: Array = []; + const { warn, logger } = makeLogger(); + + await warmStackE2eDependencies({ + hasDockerDaemon: () => true, + logger, + prefetch: async (options?: PrefetchOptions) => { + calls.push(options); + if (options?.mode === "native") throw new Error("native unavailable"); + return makeResult("docker"); + }, + }); + + expect(calls).toEqual([{ mode: "native" }, { mode: "docker" }]); + expect(warn.some((message) => message.includes("native unavailable"))).toBe(true); + }); + test("only warns when warmup is best effort", async () => { const { warn, logger } = makeLogger(); diff --git a/packages/stack/tests/parallelStacks.e2e.test.ts b/packages/stack/tests/parallelStacks.e2e.test.ts deleted file mode 100644 index 14dd7358a9..0000000000 --- a/packages/stack/tests/parallelStacks.e2e.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type ChildProcess } from "node:child_process"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { terminateChildProcess } from "../src/terminateChild.ts"; -import { type SpawnedStackInfo, spawnStandaloneStack } from "./helpers/spawn-stack.ts"; - -const STACK_COUNT = 2; -const PARALLEL_STACK_TEST_TIMEOUT_MS = 30_000; - -describe("parallel stacks (multi-process)", () => { - const stacks: SpawnedStackInfo[] = []; - // Registered at spawn time, not readiness: when one stack fails bring-up, - // `Promise.all` discards its healthy siblings' values, so this list — not - // `stacks` — is what teardown owns. - const children: ChildProcess[] = []; - - beforeAll(async () => { - const results = await Promise.all( - Array.from({ length: STACK_COUNT }, () => - spawnStandaloneStack({ onSpawn: (child) => children.push(child) }), - ), - ); - stacks.push(...results); - }, 90_000); - - afterAll(async () => { - await Promise.allSettled( - children.map((child) => terminateChildProcess(child, { timeoutMs: 30_000 })), - ); - expect(children.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe( - true, - ); - }, 60_000); - - test("all stacks use different API ports", { timeout: PARALLEL_STACK_TEST_TIMEOUT_MS }, () => { - const ports = stacks.map((s) => new URL(s.url).port); - expect(new Set(ports).size).toBe(STACK_COUNT); - }); - - test("all stacks use different DB ports", { timeout: PARALLEL_STACK_TEST_TIMEOUT_MS }, () => { - const ports = stacks.map((s) => new URL(s.dbUrl).port); - expect(new Set(ports).size).toBe(STACK_COUNT); - }); - - test( - "all stacks respond to health checks", - { timeout: PARALLEL_STACK_TEST_TIMEOUT_MS }, - async () => { - const responses = await Promise.all( - stacks.map((s) => fetch(`${s.url}/health`, { signal: AbortSignal.timeout(20_000) })), - ); - for (const res of responses) { - expect(res.status).toBe(200); - } - }, - ); -}); diff --git a/packages/stack/tests/postgresDataPersistence.e2e.test.ts b/packages/stack/tests/postgresDataPersistence.e2e.test.ts index 3fa4ab86b0..7d7da780a7 100644 --- a/packages/stack/tests/postgresDataPersistence.e2e.test.ts +++ b/packages/stack/tests/postgresDataPersistence.e2e.test.ts @@ -51,11 +51,11 @@ async function queryMarkerRows(dbPort: number): Promise { INSERT INTO public.persistence_marker (note) VALUES ('native-e2e-marker'); `); - sql.close(); + await sql.close(); }, NATIVE_SETUP_TIMEOUT_MS); afterAll(async () => { From fa8a3fb319490ef9b14136f24f2e09eb89c4c91f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:10:19 +0000 Subject: [PATCH 26/63] fix(deps): bump github.com/posthog/posthog-go from 1.23.0 to 1.23.1 in /apps/cli-go in the go-minor group across 1 directory (#6297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go-minor group with 1 update in the /apps/cli-go directory: [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go). Updates `github.com/posthog/posthog-go` from 1.23.0 to 1.23.1
Release notes

Sourced from github.com/posthog/posthog-go's releases.

1.23.1

Unreleased

Changelog

Sourced from github.com/posthog/posthog-go's changelog.

1.23.1

Patch Changes

  • ec8f6c0: Normalize event timestamps to the equivalent UTC instant before serializing legacy batch and Capture V1 payloads.
Commits
  • 608be68 chore: release v1.23.1 [version bump] [skip ci]
  • ec8f6c0 fix: normalize SDK timestamps to UTC (#284)
  • 7fb10e6 ci: remove automerge from upgrade workflow (#282)
  • 4127d7d ci: Upgrade posthog-go in the PostHog monorepo after releases (#281)
  • f7b86e8 chore(deps): bump the github-actions group with 3 updates (#280)
  • a02322f chore: group Dependabot updates (#278)
  • e6df366 chore(deps): bump github/codeql-action/analyze from 4.35.5 to 4.37.4 (#274)
  • 3a67823 chore(deps-dev): bump @​changesets/cli from 2.31.0 to 2.31.1 (#269)
  • f2742c5 chore(deps): bump github.com/goccy/go-json from 0.10.5 to 0.10.6 (#271)
  • 5bb890e chore(deps): bump actions/checkout from 6.0.2 to 7.0.1 (#273)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/posthog/posthog-go&package-manager=go_modules&previous-version=1.23.0&new-version=1.23.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 4 ++-- apps/cli-go/go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 77cf63fb5e..c08aa09d7d 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -36,7 +36,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.23.0 + github.com/posthog/posthog-go v1.23.1 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -169,7 +169,7 @@ require ( github.com/go-toolsmith/typep v1.1.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godoc-lint/godoc-lint v0.11.2 // indirect github.com/gofrs/flock v0.13.0 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index cfc857b7d2..c68a362f07 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -357,8 +357,8 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= @@ -767,8 +767,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.23.0 h1:Uj/mHGBRY+VutTMAtK79Lbha5lHCyYEuZypLtfWjQbI= -github.com/posthog/posthog-go v1.23.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= +github.com/posthog/posthog-go v1.23.1 h1:Xw8QnH1WdCjHoqEbej7FI3CfM1g0jBJb8aBqIpBvQeM= +github.com/posthog/posthog-go v1.23.1/go.mod h1:seY9mmw3mYGT9i2Wr5Dn3JXWPiAkZ6aolwH/5l8eVQE= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= From 94158474812f7b491387a909f3168f8d3b20b2e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:13:18 +0000 Subject: [PATCH 27/63] fix(deps-dev): bump the npm-major group with 3 updates (#6299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-major group with 3 updates: [pkg-pr-new](https://github.com/stackblitz-labs/pkg.pr.new/tree/HEAD/packages/cli), [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) and [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node). Updates `pkg-pr-new` from 0.0.87 to 0.0.88
Commits

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.232 to 0.3.233
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.233

What's changed

  • Notification hooks now fire for pending permission prompts on the SDK path, matching the interactive REPL behavior
  • Todo/task-tracking tools (TaskCreate/TaskGet/TaskUpdate/TaskList, TodoWrite) are no longer in the default tool surface on Opus 4.8, Sonnet 5, Fable 5, Mythos 5, and newer models; name them in the tools option or reference them in allowedTools (or set CLAUDE_CODE_ENABLE_TODO_TOOLS=1) to keep them

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.233
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.233
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.233
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.233
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.233

  • Notification hooks now fire for pending permission prompts on the SDK path, matching the interactive REPL behavior
  • Todo/task-tracking tools (TaskCreate/TaskGet/TaskUpdate/TaskList, TodoWrite) are no longer in the default tool surface on Opus 4.8, Sonnet 5, Fable 5, Mythos 5, and newer models; name them in the tools option or reference them in allowedTools (or set CLAUDE_CODE_ENABLE_TODO_TOOLS=1) to keep them
Commits

Updates `posthog-node` from 5.49.0 to 5.49.1
Release notes

Sourced from posthog-node's releases.

posthog-node@5.49.1

5.49.1

Patch Changes

  • #4521 0a0206f Thanks @​marandaneto! - Normalize capture timestamp overrides to equivalent UTC ISO strings in the browser and Node.js SDKs and shared core. (2026-08-14)
  • Updated dependencies [0a0206f]:
    • @​posthog/core@​1.48.1
Changelog

Sourced from posthog-node's changelog.

5.49.1

Patch Changes

  • #4521 0a0206f Thanks @​marandaneto! - Normalize capture timestamp overrides to equivalent UTC ISO strings in the browser and Node.js SDKs and shared core. (2026-08-14)
  • Updated dependencies [0a0206f]:
    • @​posthog/core@​1.48.1
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 4 +- package.json | 2 +- pnpm-lock.yaml | 110 +++++++++++++++++++++--------------------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 96706fd08e..f7ceb4f910 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,7 +43,7 @@ "jose": "^6.2.8" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.232", + "@anthropic-ai/claude-agent-sdk": "^0.3.233", "@anthropic-ai/sdk": "^0.117.1", "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", @@ -77,7 +77,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.23.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.49.0", + "posthog-node": "^5.49.1", "react": "^19.2.8", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", diff --git a/package.json b/package.json index c4f6354350..f51677da66 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", - "pkg-pr-new": "0.0.87", + "pkg-pr-new": "0.0.88", "typescript": "catalog:", "verdaccio": "^6.9.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3389356a6d..1c4969ce34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,8 +84,8 @@ importers: specifier: 'catalog:' version: 7.0.2001 pkg-pr-new: - specifier: 0.0.87 - version: 0.0.87 + specifier: 0.0.88 + version: 0.0.88 typescript: specifier: 'catalog:' version: 7.0.2 @@ -103,8 +103,8 @@ importers: version: 6.2.8 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.232 - version: 0.3.232(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.233 + version: 0.3.233(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': specifier: ^0.117.1 version: 0.117.1(zod@4.4.3) @@ -205,8 +205,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.49.0 - version: 5.49.0 + specifier: ^5.49.1 + version: 5.49.1 react: specifier: ^19.2.8 version: 19.2.8 @@ -582,52 +582,52 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.232': - resolution: {integrity: sha512-+/4PX+dwmQAjlOlooocwa3kClulZfMo133xQH3LYDlK7D5bzze16lwlDGPVAYGEarpvXg7G5JK8QjfWAJ2HYbg==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233': + resolution: {integrity: sha512-4WDiBZgcrmvTDJjS8RNZwoxGgMz/0EpOM+sYa6EtyjwHTd6It1H/+k5zBckCmBajbgS5/ASCJqdwZzi7dwBl0Q==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.232': - resolution: {integrity: sha512-EHZ1Y3aGyZ2mFZ6QLR1bM3/HiIn2cLrPjU+k3/oCIW6omJFodfzf410aWjDPSnMj7CE4d6t7MSjBWekMUbcv0g==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233': + resolution: {integrity: sha512-RaaEfNrbqSh77H5NdVF9cJQ0xhAUO92aOv71LSKSdAYModMeUvJN0k22Q7gvmx0TlmqJ+aVyCG8J8gVfgSL9mg==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.232': - resolution: {integrity: sha512-XkLcb9UT/l42Rtw7KBApzgxUe/kwoWJ9KCPcVEnYojzIvVR+AwBCl/QReNU5+6c72w48dYBMmLebI64gQbN0tg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233': + resolution: {integrity: sha512-Z3uZdzt6xgJ3f4NIgO6lzBYSELULKSq6AL4OsNLBzuaEpVW0iYs1kUCaD9rcMlMrf3cV+Dk/GA/lTCGMgbucjQ==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.232': - resolution: {integrity: sha512-wW2opwA5s7gLghjU6B2ADMAtoc7bAZMevUzi4g+1PXMJ8MGcPvbnx92EDYJrVDcZmAl1+fz19XyPsaShlisTzw==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233': + resolution: {integrity: sha512-Az9HjQthYQqRjJCacBtDIAHX3TRGK9WlACNb/UOGAK3JndNzZMprM2mK/t6YmP2cRLJsGyorxL7HZmR9R9HYaw==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.232': - resolution: {integrity: sha512-L1x2ge9NpXMLTczmT44TKPQ88PHE+gsCakQMVEOa8rXFvfBoqvcpxM0DT8wrqYWUHhQEYR8NXxQTP9a1NVRj8Q==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233': + resolution: {integrity: sha512-kYBIAQCu2f1YITcGbpUN2jfrkAzs59TVAragAhE2z+GrkIcxcpZwmaRY6heMBtaSY8SuyrwgqbCW9hJALYFnEg==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.232': - resolution: {integrity: sha512-6Px1xDiwQyLkSxwRQ34/kPA8WMXQ2rHYGkwockND7+9yMw+ShI3AfLkyi9G7JtJHObWFT6B82eSHjDS/Fy+9hQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233': + resolution: {integrity: sha512-jpbhV+n9PnxLiyheQ/HjtHIg/E5/jVsk2Vdu132BSoL/3bsObSmMqKgsqoMutzwRZvtpqRs2RPVcjsC8G4A9Zw==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.232': - resolution: {integrity: sha512-fDiuwL5dm1elOy7fNp4Qmdor8so4R8npj2pUGQA1G/xKfJhaK236aTWezvZid3L/O7cRdFeN9IVofOAlWLQcsw==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233': + resolution: {integrity: sha512-aO2MaNdmQofyPLKszE4s+Ope/sLJPeI/ZlGdCcjYp7qhji2hgZ4bRWWsOrx5eKjz0gFK5CFFltILkFcNcxCsVg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.232': - resolution: {integrity: sha512-Hc/9uy1BI9mqKVyB1b/zoUnm3MFgtVNzQY6p5zgaq9DaIjCKDpR4a4L1aDZM4lMqUcWFMZM+u7juOhh+m39NBQ==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233': + resolution: {integrity: sha512-TcAYyWPXS5mREZGUksuCZsLIRQjbo/Vriur2PqIhAmgZ1oiqBZO27a90sX60EUczD7yV8wpwOVhVLhUxO0kAEg==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.232': - resolution: {integrity: sha512-8od7hJk9fZnF1/oYYiR9PvroGbZRQrpmNgKirjHNGoj5ur5YcAZLohI70XVUAUe3KvjB1msLxtkvmlAT9sqFAg==} + '@anthropic-ai/claude-agent-sdk@0.3.233': + resolution: {integrity: sha512-Dy+YqhggwtbezDy3Ap2pb1sK3bOqnI+sLNnsVjB3AUWvR0QlGnjjrjORXY03Y50I+B1eFRNEcYPAZKRYlCkSLQ==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -2145,11 +2145,11 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@posthog/core@1.48.0': - resolution: {integrity: sha512-ezKjVLw9y3Q235PUY+2hRr5DN9t6j2jF1mFAvnvhCCu/Ha6/qBv3zmVJBaHu9PnqqSNtx2St3ggN8Z3TTu/12A==} + '@posthog/core@1.48.1': + resolution: {integrity: sha512-mxw31XdYgt/SnlwqLPAcltK67q+QmsiYjVLGQ4GbBc8OJ7O4yRFSDwAXt8QsokHokeyEZtTRWM6jDHL7LYMx1A==} - '@posthog/types@1.404.0': - resolution: {integrity: sha512-/Y1zKv8SdwkK725SkmgT5QVYnXE7Fi23SPDCZ5Ybu27gTQub4yMTKWuUQekW+gkSKBZ0LyYCQK52mhyMMigMBw==} + '@posthog/types@1.404.1': + resolution: {integrity: sha512-i2Gei6ARfOSBeTN4s2yUP1p97s2UNI+1NWmtLjhnR/V6t3RFOfI1sBWKcJNWHjtoOCCWoAFU+PNPY6SgT2VtEQ==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -5793,8 +5793,8 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} - pkg-pr-new@0.0.87: - resolution: {integrity: sha512-nm+30Py1csXWfyMH1ueQyTR11IZGHS5oW8Qok/MxMwjPx9g1jX3wMRrJf8TgEvNo0a0M4i14T0zQsEPWdZfAhg==} + pkg-pr-new@0.0.88: + resolution: {integrity: sha512-Xc6PMJ2gher0WZP+rtjefFk26hIb7V1PTLL30bmy1Z2vsRmSvqiss7Ag1XUdyplTGYzIlFNJp4L3vMNmK44N6g==} hasBin: true plpgsql-deparser@0.7.13: @@ -5846,8 +5846,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.49.0: - resolution: {integrity: sha512-w3vPYmiWIWw0XlRDeRH0TbeRKnHvlQcU7xDwDN7jNm6JpoFQDUyValGjBpQ+Qvv6gmYUaNM2XBSJeeqcB1FtCQ==} + posthog-node@5.49.1: + resolution: {integrity: sha512-kmjvzc7K8y+wQX7r3oIkLfKc2BzDJPXMJor5D8gAxpGE7qLNlk4HO2y1brc5587GxtdsZzqtGOxfBJXUBE2MiQ==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -7043,44 +7043,44 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.232': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.232': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.232': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.232': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.232': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.232': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.232': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.232': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.232(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.233(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.117.1(zod@4.4.3) '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.232 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.232 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.232 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.232 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.232 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.232 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.232 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.232 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.233 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.233 '@anthropic-ai/sdk@0.117.1(zod@4.4.3)': dependencies: @@ -8232,11 +8232,11 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@posthog/core@1.48.0': + '@posthog/core@1.48.1': dependencies: - '@posthog/types': 1.404.0 + '@posthog/types': 1.404.1 - '@posthog/types@1.404.0': {} + '@posthog/types@1.404.1': {} '@protobufjs/aspromise@1.1.2': {} @@ -12306,7 +12306,7 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 - pkg-pr-new@0.0.87: {} + pkg-pr-new@0.0.88: {} plpgsql-deparser@0.7.13: dependencies: @@ -12357,9 +12357,9 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.49.0: + posthog-node@5.49.1: dependencies: - '@posthog/core': 1.48.0 + '@posthog/core': 1.48.1 pretty-ms@9.3.0: dependencies: From 44112f6e17cf535a6939a565da9608003c716561 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:12:13 +0000 Subject: [PATCH 28/63] fix(deps): bump the npm-major group with 2 updates (#6301) Bumps the npm-major group with 2 updates: [jose](https://github.com/panva/jose) and [@tsconfig/bun](https://github.com/tsconfig/bases/tree/HEAD/bases). Updates `jose` from 6.2.8 to 6.2.9
Release notes

Sourced from jose's releases.

v6.2.9

Fixes

  • reject a JWE whose generated Key Management Parameters collide (6ed19a6)
  • types: undeprecate PBES2 p2c parameter (33bf832)
Changelog

Sourced from jose's changelog.

6.2.9 (2026-08-15)

Fixes

  • reject a JWE whose generated Key Management Parameters collide (6ed19a6)
  • types: undeprecate PBES2 p2c parameter (33bf832)
Commits
  • f3a3c78 chore(release): 6.2.9
  • 33bf832 fix(types): undeprecate PBES2 p2c parameter
  • 6ed19a6 fix: reject a JWE whose generated Key Management Parameters collide
  • 944840d ci: use shared release workflows
  • 05bccf2 chore: bump packages
  • f7392d1 test: account for workerd nodejs_compat flag default changes
  • 4e944be ci: drop the wait-for-npm machinery
  • 4285b6f chore(deps-dev): bump undici
  • cb114ec chore: cleanup after release
  • See full diff in compare view

Updates `@tsconfig/bun` from 1.0.10 to 1.0.11
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- pnpm-lock.yaml | 36 ++++++++++++++++++------------------ pnpm-workspace.yaml | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index f7ceb4f910..225910820c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "eciesjs": "^0.5.0", - "jose": "^6.2.8" + "jose": "^6.2.9" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.233", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c4969ce34..ca416dc003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ catalogs: specifier: ^23.1.1 version: 23.1.1 '@tsconfig/bun': - specifier: ^1.0.10 - version: 1.0.10 + specifier: ^1.0.11 + version: 1.0.11 '@types/bun': specifier: ^1.4.0 version: 1.4.0 @@ -99,8 +99,8 @@ importers: specifier: ^0.5.0 version: 0.5.0 jose: - specifier: ^6.2.8 - version: 6.2.8 + specifier: ^6.2.9 + version: 6.2.9 devDependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.233 @@ -152,7 +152,7 @@ importers: version: link:../../packages/stack '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -265,7 +265,7 @@ importers: devDependencies: '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -345,7 +345,7 @@ importers: devDependencies: '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -387,7 +387,7 @@ importers: devDependencies: '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -437,7 +437,7 @@ importers: devDependencies: '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -477,7 +477,7 @@ importers: version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -526,7 +526,7 @@ importers: version: 2.112.3 '@tsconfig/bun': specifier: 'catalog:' - version: 1.0.10 + version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 @@ -2803,8 +2803,8 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tsconfig/bun@1.0.10': - resolution: {integrity: sha512-5AV5YknQjNyoYzZ/8NG0dawqew/wH+x7ANiCfCIn29qo0cdbd1EryvFD1k5NSZWLBMOI/fGqMIaxi58GPIP9Cg==} + '@tsconfig/bun@1.0.11': + resolution: {integrity: sha512-DQA3HOKFQ+/yPTUa73k5QGcuEM1ExtL6uWQMR0QrfOTitm0oLHqHd1tI74WtEtMqlIu+tvSMELJ6p5uNxVE7lw==} '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -4736,8 +4736,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.8: - resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -7647,7 +7647,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.6.1(express@5.2.1) hono: 4.12.32 - jose: 6.2.8 + jose: 6.2.9 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -8863,7 +8863,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@tsconfig/bun@1.0.10': {} + '@tsconfig/bun@1.0.11': {} '@tybys/wasm-util@0.10.3': dependencies: @@ -10913,7 +10913,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.8: {} + jose@6.2.9: {} js-tokens@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8d24ddba32..45f3012f29 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,7 +18,7 @@ catalog: "@effect/sql-pg": "4.0.0-rc.111" "@effect/vitest": "4.0.0-rc.111" "@nx/devkit": "^23.1.1" - "@tsconfig/bun": "^1.0.10" + "@tsconfig/bun": "^1.0.11" "@types/bun": "^1.4.0" "typescript": "^7.0.2" "@vitest/coverage-istanbul": "^4.1.10" From 4252cd9336740807f7cfa5b28aee313699d45bdf Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Mon, 24 Aug 2026 08:01:23 +0000 Subject: [PATCH 29/63] feat(cli): upgrade pg-delta next to alpha.46 (#6300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bump `@supabase/pg-delta` from `1.0.0-alpha.42` to `1.0.0-alpha.46` so `db diff`, `db pull`, and `db schema declarative generate` pick up supabase-profile parameter-ACL filtering, `OWNED BY` with the owning table, per-statement load fallback, `vault_presence`, and reconnect-on-stuck load assist. - Delete the CLI copy of platform parameter-ACL filtering; the engine profile now owns that coverage. - Pretty-print generated SQL by default (uppercase keywords, indent 2, aligned columns). - Prepare declarative shadows only when files recreate image defaults (`pgjwt` / `pgcrypto` / `uuid-ossp`); omit means keep. Restore image `pgjwt` after a pgcrypto-only drop only if it was installed. On PG14, detach `storage.objects.id` before dropping `uuid-ossp`. - CREATE EXTENSION detection is `--` / `/* */` / simple `'...'` plus a regex (pathological SQL is an accepted miss). Prep uses `pool.query`; a locked DROP can delay Ctrl-C like other sites. Extracted from #6274 so the engine upgrade can land on `develop` without the schema-first command stack. ## Linked issue Supabase maintainer change; no public issue to close. - [x] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). ## Checklist - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat(cli): …`). - [x] Tests added or updated for the change. - [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. --- apps/cli/docs/supabase/db/diff.md | 2 +- .../db/schema-declarative-generate.md | 2 + apps/cli/package.json | 2 +- .../db/schema/declarative/declarative.flow.ts | 35 +-- .../legacy-pgdelta-declarative-shadow-prep.ts | 152 +++++++++++++ ...delta-declarative-shadow-prep.unit.test.ts | 166 ++++++++++++++ .../legacy-pgdelta-engine.next.layer.ts | 9 +- .../shared/legacy-pgdelta-engine.service.ts | 4 +- .../db/shared/legacy-pgdelta-files.ts | 4 + .../legacy-pgdelta-next-adapter.layer.ts | 212 ++++++------------ .../legacy-pgdelta-next-adapter.service.ts | 1 + .../legacy-pgdelta-next-adapter.unit.test.ts | 134 ++++++----- .../legacy-pgdelta-next-shadow.layer.ts | 34 +-- ...acy-pgdelta-next-shadow.layer.unit.test.ts | 44 ---- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 2 +- 16 files changed, 495 insertions(+), 318 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.unit.test.ts diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index fb407e4349..6ad22f7e1a 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -12,7 +12,7 @@ By default, all schemas in the target database are diffed. Use the `--schema pub Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run. -With the bundled pg-delta engine, diff SQL defaults to lowercase keywords and a maximum width of 180, matching its declarative export. When `-f` writes migrations, execution-aware transaction semantics are preserved as ordered per-unit files; non-transactional units carry a directive that the CLI apply path honors. Flattened review output retains the rendered SQL and preambles, but not the unit boundaries supplied to a migration runner. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. +With the bundled pg-delta engine, diff SQL defaults to uppercase keywords, indent 2, a maximum width of 180, trailing commas, and column/key alignment, matching its declarative export. When `-f` writes migrations, execution-aware transaction semantics are preserved as ordered per-unit files; non-transactional units carry a directive that the CLI apply path honors. Flattened review output retains the rendered SQL and preambles, but not the unit boundaries supplied to a migration runner. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains: diff --git a/apps/cli/docs/supabase/db/schema-declarative-generate.md b/apps/cli/docs/supabase/db/schema-declarative-generate.md index 164176d6c0..1cd416e747 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli/docs/supabase/db/schema-declarative-generate.md @@ -6,4 +6,6 @@ Exports the schema of a live database (local, linked, or custom URL) into SQL fi The bundled pg-delta engine writes one directory per schema at the root of that directory (`supabase/schemas/public/tables/users.sql`, `supabase/schemas/public/schema.sql`), with cluster-level objects that belong to no schema under a reserved `_cluster/` directory (`supabase/schemas/_cluster/roles.sql`). A schema literally named `_cluster` or `_custom`, in any casing, has its leading underscore percent-encoded (`%5Fcluster/`) so it can never claim a directory the export owns. Hand-authored SQL that pg-delta does not model belongs in `_custom/`, which the export never writes to and never prunes. +Emitted SQL uses the same default format as `db pull` (uppercase keywords, indent 2, width 180, column-aligned). Override with `[experimental.pgdelta] format_options`, or set `format_options = "null"` for raw statements. + Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. diff --git a/apps/cli/package.json b/apps/cli/package.json index 225910820c..c92c1b8695 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,7 +55,7 @@ "@parcel/watcher": "^2.6.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", - "@supabase/pg-delta": "1.0.0-alpha.42", + "@supabase/pg-delta": "1.0.0-alpha.46", "@supabase/pg-topo": "1.0.0-alpha.5", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts index 06669d82c3..bdf22189e6 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts @@ -1,5 +1,9 @@ import type { LegacyPgDeltaImplementation } from "../../../../shared/legacy-pgdelta-next-flag.ts"; import { legacySchemaToCsvField } from "../../../../shared/legacy-schema-flags.ts"; +import { + legacyDeclaredSqlExtensions, + legacyMaskSqlComments, +} from "../../shared/legacy-pgdelta-declarative-shadow-prep.ts"; import type { LegacyPgDeltaRemovalSummary } from "../../shared/legacy-pgdelta-engine.service.ts"; /** Extensions that legacy pg-delta treated as part of its implicit Supabase baseline. */ @@ -159,37 +163,10 @@ function matchImplicitExtension(message: string): LegacyImplicitExtensionMatch | }; } -/** - * Masks SQL comments and strings while preserving offsets. Extension declarations - * are DDL, so occurrences inside comments, quoted values, and dollar bodies must - * not suppress compatibility guidance. - */ -function maskSqlNonCode(sql: string): string { - return sql.replaceAll( - /--[^\r\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|\$(?:[a-zA-Z_][\w$]*)?\$[\s\S]*?\$(?:[a-zA-Z_][\w$]*)?\$/g, - (matched) => matched.replaceAll(/[^\r\n]/g, " "), - ); -} - -function maskSqlComments(sql: string): string { - return sql.replaceAll(/--[^\r\n]*|\/\*[\s\S]*?\*\//g, (matched) => - matched.replaceAll(/[^\r\n]/g, " "), - ); -} - export function legacyDeclaredExtensions( files: readonly LegacyDeclarativeSqlFile[], ): ReadonlySet { - const declared = new Set(); - const pattern = - /\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][\w$-]*))/gi; - for (const file of files) { - for (const match of maskSqlNonCode(file.sql).matchAll(pattern)) { - const extension = match[1] ?? match[2]; - if (extension !== undefined) declared.add(extension.toLowerCase()); - } - } - return declared; + return legacyDeclaredSqlExtensions(files); } function declaredImplicitExtensions( @@ -210,7 +187,7 @@ function locateSignature( const diagnosticFile = files.find((file) => diagnosticMessage.startsWith(`${file.name}:`)); const candidates = diagnosticFile === undefined ? files : [diagnosticFile]; for (const file of candidates) { - const match = pattern.exec(maskSqlComments(file.sql)); + const match = pattern.exec(legacyMaskSqlComments(file.sql)); if (match?.index === undefined) continue; return { file: file.name, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts new file mode 100644 index 0000000000..93b399af25 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts @@ -0,0 +1,152 @@ +import { Effect } from "effect"; + +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; + +export type LegacyDeclarativeShadowClient = { + readonly query: (sql: string) => Promise<{ readonly rows: ReadonlyArray }>; +}; + +export interface LegacyDeclarativeShadowPrepResult { + /** True only when prep dropped an installed image pgjwt to recreate pgcrypto. */ + readonly restorePgjwt: boolean; +} + +/** Image-default extensions the user may still declare; omit means keep the install. */ +const IMAGE_DEFAULT_EXTENSIONS = ["pgjwt", "pgcrypto", "uuid-ossp"] as const; + +const IMAGE_DEFAULT_EXTENSION_SET = new Set(IMAGE_DEFAULT_EXTENSIONS); + +const DROP_IMAGE_DEFAULT_EXTENSION: Record<(typeof IMAGE_DEFAULT_EXTENSIONS)[number], string> = { + pgjwt: "DROP EXTENSION IF EXISTS pgjwt", + pgcrypto: "DROP EXTENSION IF EXISTS pgcrypto", + "uuid-ossp": 'DROP EXTENSION IF EXISTS "uuid-ossp"', +}; + +const CREATE_EXTENSION_RE = + /\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][\w$-]*))/gi; + +/** Blank comments and simple strings; keep offsets for locateSignature line mapping. */ +export const legacyMaskSqlComments = (sql: string): string => + sql.replaceAll(/--[^\r\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'/g, (matched) => + matched.replaceAll(/[^\r\n]/g, " "), + ); + +export const legacyDeclaredSqlExtensions = ( + files: ReadonlyArray<{ readonly name: string; readonly sql: string }>, +): ReadonlySet => { + const declared = new Set(); + for (const file of files) { + for (const match of legacyMaskSqlComments(file.sql).matchAll(CREATE_EXTENSION_RE)) { + const name = (match[1] ?? match[2] ?? "").toLowerCase(); + if (name !== "") declared.add(name); + } + } + return declared; +}; + +const declaredImageExtensions = ( + files: ReadonlyArray<{ readonly name: string; readonly sql: string }>, +): ReadonlySet => { + const declared = new Set(); + for (const name of legacyDeclaredSqlExtensions(files)) { + if (IMAGE_DEFAULT_EXTENSION_SET.has(name)) declared.add(name); + } + return declared; +}; + +const legacyParsePostgresMajorVersion = (serverVersion: string): number => { + const major = Number.parseInt(serverVersion, 10); + return Number.isInteger(major) ? major : 0; +}; + +const legacyDeclarativeBaselinePrepStatements = ( + majorVersion: number, + declared: ReadonlySet, +): ReadonlyArray => { + const dropPgcrypto = declared.has("pgcrypto"); + // Image pgjwt depends on pgcrypto; drop it first so pgcrypto can drop. + const dropPgjwt = declared.has("pgjwt") || dropPgcrypto; + const dropUuidOssp = declared.has("uuid-ossp"); + const statements: string[] = []; + if (majorVersion === 14 && dropUuidOssp) { + statements.push("ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT"); + } + if (dropPgjwt) statements.push(DROP_IMAGE_DEFAULT_EXTENSION.pgjwt); + if (dropPgcrypto) statements.push(DROP_IMAGE_DEFAULT_EXTENSION.pgcrypto); + if (dropUuidOssp) statements.push(DROP_IMAGE_DEFAULT_EXTENSION["uuid-ossp"]); + return statements; +}; + +/** Recreate image pgjwt after a pgcrypto-only drop so omit still means keep. */ +export const legacyFilesForDeclarativeShadowLoad = ( + files: ReadonlyArray<{ readonly name: string; readonly sql: string }>, + restorePgjwt: boolean, +): ReadonlyArray<{ readonly name: string; readonly sql: string }> => { + if (!restorePgjwt) return files; + return [ + ...files, + { + name: "_cli/restore-pgjwt.sql", + sql: "CREATE EXTENSION IF NOT EXISTS pgjwt WITH SCHEMA extensions;\n", + }, + ]; +}; + +/** User cannot edit this SQL; a persistent miss is a CLI bug. */ +const DECLARATIVE_SHADOW_PREP_FAILURE_SUGGESTION = + "This statement is CLI-owned shadow prep, not a project migration or schema file. If it persists, report it with supabase issue bug."; + +const queryError = (sql: string, cause: unknown) => + new LegacyPgDeltaEngineError({ + message: `Failed to prepare the isolated declaration shadow (${sql}): ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + suggestion: DECLARATIVE_SHADOW_PREP_FAILURE_SUGGESTION, + }); + +const readServerVersion = (rows: ReadonlyArray): string => { + const row = rows[0]; + if (row === undefined || typeof row !== "object" || row === null) return ""; + const value = Reflect.get(row, "server_version"); + return typeof value === "string" ? value : ""; +}; + +const rowHasPgjwt = (rows: ReadonlyArray): boolean => + rows.some((row) => { + if (typeof row !== "object" || row === null) return false; + const name = Reflect.get(row, "extname"); + return name === "pgjwt"; + }); + +const INSTALLED_PGJWT_SQL = "SELECT extname FROM pg_extension WHERE extname = 'pgjwt'"; + +const queryShadow = (client: LegacyDeclarativeShadowClient, sql: string) => + Effect.tryPromise({ + try: () => client.query(sql), + catch: (cause) => queryError(sql, cause), + }); + +export const legacyPrepareDeclarativeShadow = ( + client: LegacyDeclarativeShadowClient, + files: ReadonlyArray<{ readonly name: string; readonly sql: string }>, +) => + Effect.gen(function* () { + const declared = declaredImageExtensions(files); + if (declared.size === 0) + return { restorePgjwt: false } satisfies LegacyDeclarativeShadowPrepResult; + let restorePgjwt = false; + if (declared.has("pgcrypto") && !declared.has("pgjwt")) { + const installed = yield* queryShadow(client, INSTALLED_PGJWT_SQL); + restorePgjwt = rowHasPgjwt(installed.rows); + } + const versionRows = yield* queryShadow(client, "SHOW server_version"); + const statements = legacyDeclarativeBaselinePrepStatements( + legacyParsePostgresMajorVersion(readServerVersion(versionRows.rows)), + declared, + ); + for (const sql of statements) { + yield* queryShadow(client, sql); + } + return { restorePgjwt } satisfies LegacyDeclarativeShadowPrepResult; + }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.unit.test.ts new file mode 100644 index 0000000000..1ed272209d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.unit.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; + +import { + legacyDeclaredSqlExtensions, + legacyFilesForDeclarativeShadowLoad, + legacyPrepareDeclarativeShadow, + type LegacyDeclarativeShadowClient, +} from "./legacy-pgdelta-declarative-shadow-prep.ts"; +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; + +const fakeShadowClient = ( + query: (sql: string) => Promise<{ readonly rows: ReadonlyArray }>, +): LegacyDeclarativeShadowClient => ({ query }); + +const allImageCreates = [ + { name: "_cluster/extensions/pgjwt.sql", sql: 'CREATE EXTENSION "pgjwt";' }, + { name: "_cluster/extensions/pgcrypto.sql", sql: 'CREATE EXTENSION "pgcrypto";' }, + { name: "_cluster/extensions/uuid-ossp.sql", sql: 'CREATE EXTENSION "uuid-ossp";' }, +]; + +describe("legacyDeclaredSqlExtensions", () => { + it("ignores CREATE EXTENSION in comments and simple strings", () => { + expect( + legacyDeclaredSqlExtensions([ + { + name: "commented.sql", + sql: "-- CREATE EXTENSION pgcrypto;\n/* CREATE EXTENSION pgjwt */\nselect 'create extension uuid-ossp';", + }, + ]), + ).toEqual(new Set()); + expect( + legacyDeclaredSqlExtensions([ + { + name: "real.sql", + sql: '-- skip me\nCREATE EXTENSION IF NOT EXISTS "uuid-ossp";', + }, + ]), + ).toEqual(new Set(["uuid-ossp"])); + }); +}); + +describe("legacyFilesForDeclarativeShadowLoad", () => { + it("restores omitted pgjwt only when prep dropped an installed image copy", () => { + const files = [{ name: "public/01.sql", sql: "CREATE EXTENSION pgcrypto;" }]; + expect(legacyFilesForDeclarativeShadowLoad(files, false)).toEqual(files); + expect(legacyFilesForDeclarativeShadowLoad(files, true)).toEqual([ + ...files, + { + name: "_cli/restore-pgjwt.sql", + sql: "CREATE EXTENSION IF NOT EXISTS pgjwt WITH SCHEMA extensions;\n", + }, + ]); + }); +}); + +describe("legacyPrepareDeclarativeShadow", () => { + it.live("skips the shadow when declarations omit image-default extensions", () => { + const queries: string[] = []; + const client = fakeShadowClient((sql) => { + queries.push(sql); + return Promise.resolve({ rows: [] }); + }); + return Effect.gen(function* () { + const prep = yield* legacyPrepareDeclarativeShadow(client, [ + { name: "a.sql", sql: "create table a (id int);" }, + ]); + expect(prep.restorePgjwt).toBe(false); + expect(queries).toEqual([]); + }); + }); + + it.live("names the failing prep statement", () => { + const client = fakeShadowClient((sql) => { + if (sql === "SHOW server_version") { + return Promise.resolve({ rows: [{ server_version: "15.8" }] }); + } + if (sql.includes("pgcrypto")) { + return Promise.reject(new Error("cannot drop extension pgcrypto (SQLSTATE 2BP01)")); + } + return Promise.resolve({ rows: [] }); + }); + return Effect.gen(function* () { + const exit = yield* legacyPrepareDeclarativeShadow(client, [ + { name: "public/01.sql", sql: "CREATE EXTENSION pgcrypto;" }, + ]).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) + : undefined; + expect(error).toBeInstanceOf(LegacyPgDeltaEngineError); + expect(error instanceof LegacyPgDeltaEngineError ? error.message : "").toContain( + "DROP EXTENSION IF EXISTS pgcrypto", + ); + }); + }); + + it.live("runs the version-selected prep statements against the shadow", () => { + const queries: string[] = []; + const client = fakeShadowClient((sql) => { + queries.push(sql); + return Promise.resolve({ + rows: sql === "SHOW server_version" ? [{ server_version: "17.6" }] : [], + }); + }); + return Effect.gen(function* () { + const prep = yield* legacyPrepareDeclarativeShadow(client, allImageCreates); + expect(prep.restorePgjwt).toBe(false); + expect(queries).toEqual([ + "SHOW server_version", + "DROP EXTENSION IF EXISTS pgjwt", + "DROP EXTENSION IF EXISTS pgcrypto", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', + ]); + }); + }); + + it.live("detaches PG14 storage.objects before dropping declared uuid-ossp", () => { + const queries: string[] = []; + const client = fakeShadowClient((sql) => { + queries.push(sql); + return Promise.resolve({ + rows: sql === "SHOW server_version" ? [{ server_version: "14.15" }] : [], + }); + }); + return Effect.gen(function* () { + yield* legacyPrepareDeclarativeShadow(client, [ + { name: "uuid.sql", sql: 'CREATE EXTENSION "uuid-ossp";' }, + ]); + expect(queries).toEqual([ + "SHOW server_version", + "ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT", + 'DROP EXTENSION IF EXISTS "uuid-ossp"', + ]); + }); + }); + + it.live("restores pgjwt only when the image had it installed", () => { + const queries: string[] = []; + const withPgjwt = fakeShadowClient((sql) => { + queries.push(sql); + if (sql.startsWith("SELECT extname")) { + return Promise.resolve({ rows: [{ extname: "pgjwt" }] }); + } + return Promise.resolve({ + rows: sql === "SHOW server_version" ? [{ server_version: "17.6" }] : [], + }); + }); + const withoutPgjwt = fakeShadowClient((sql) => + Promise.resolve({ + rows: sql === "SHOW server_version" ? [{ server_version: "17.6" }] : [], + }), + ); + const files = [{ name: "public/01.sql", sql: "CREATE EXTENSION pgcrypto;" }]; + return Effect.gen(function* () { + expect((yield* legacyPrepareDeclarativeShadow(withPgjwt, files)).restorePgjwt).toBe(true); + expect(queries).toEqual([ + "SELECT extname FROM pg_extension WHERE extname = 'pgjwt'", + "SHOW server_version", + "DROP EXTENSION IF EXISTS pgjwt", + "DROP EXTENSION IF EXISTS pgcrypto", + ]); + expect((yield* legacyPrepareDeclarativeShadow(withoutPgjwt, files)).restorePgjwt).toBe(false); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts index b925e23d40..f1cdb92d45 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -10,6 +10,10 @@ import { import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + legacyFilesForDeclarativeShadowLoad, + legacyPrepareDeclarativeShadow, +} from "./legacy-pgdelta-declarative-shadow-prep.ts"; import { LegacyPgDeltaEngine, LegacyPgDeltaEngineError, @@ -27,11 +31,11 @@ import { legacySavePgDeltaNextDebugArtifacts, type LegacyPgDeltaNextDebugArtifacts, } from "./legacy-pgdelta-next-artifacts.ts"; -import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; import { legacyPgDeltaNextDiagnosticReport, legacyReportPgDeltaNextDiagnostics, } from "./legacy-pgdelta-next-diagnostics.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; function legacyPgDeltaNextConnectSuggestion(cause: unknown): string | undefined { if (cause instanceof LegacyDbConnectError) return cause.suggestion; @@ -349,10 +353,11 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( ], { concurrency: 2 }, ); + const prep = yield* legacyPrepareDeclarativeShadow(declarativePool, input.files); const result = yield* adapter.planDeclarativeSchema({ targetPool: migrationsPool, shadowPool: declarativePool, - files: input.files, + files: legacyFilesForDeclarativeShadowLoad(input.files, prep.restorePgjwt), allowDrops: true, debug: input.debug, schema: input.schema, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts index 7f71f6814a..837a057614 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -43,6 +43,7 @@ export interface LegacyPgDeltaExportManifest { readonly baselineDigest?: string; readonly defaultOwner?: string | null; readonly files?: ReadonlyArray; + readonly loadOrder?: ReadonlyArray; } export interface LegacyPgDeltaRenderedFile { @@ -75,7 +76,8 @@ export type LegacyPgDeltaHazardKind = | "access_exclusive_lock" | "unmodeled_kind" | "unmodeled_drift" - | "unresolved_security_label"; + | "unresolved_security_label" + | "vault_presence"; interface LegacyPgDeltaActionHazard { readonly actionIndex: number; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts index e73738e446..91c98882d0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -76,6 +76,7 @@ export const LegacyReadPgDeltaExportManifest = Effect.fnUntraced(function* ( const baselineDigest = readManifestValue(decoded, "baselineDigest"); const defaultOwner = readManifestValue(decoded, "defaultOwner"); const files = readManifestValue(decoded, "files"); + const loadOrder = readManifestValue(decoded, "loadOrder"); return { redactSecrets, scope, @@ -83,6 +84,9 @@ export const LegacyReadPgDeltaExportManifest = Effect.fnUntraced(function* ( ...(typeof baselineDigest === "string" ? { baselineDigest } : {}), ...(typeof defaultOwner === "string" || defaultOwner === null ? { defaultOwner } : {}), ...(Array.isArray(files) && files.every((file) => typeof file === "string") ? { files } : {}), + ...(Array.isArray(loadOrder) && loadOrder.every((file) => typeof file === "string") + ? { loadOrder } + : {}), } satisfies LegacyPgDeltaExportManifest; }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts index dec237f75c..7b66c0f06c 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts @@ -234,6 +234,27 @@ function legacyTryPgDeltaNext( }); } +function legacyIsLibraryDiagnostic( + value: unknown, +): value is LegacyPgDeltaNextLibraryDiagnostic { + if (typeof value !== "object" || value === null) return false; + const severity = Reflect.get(value, "severity"); + return ( + typeof Reflect.get(value, "code") === "string" && + typeof Reflect.get(value, "message") === "string" && + (severity === "error" || severity === "warning" || severity === "info") + ); +} + +function legacyReadPlanDiagnostics( + plan: unknown, +): readonly LegacyPgDeltaNextLibraryDiagnostic[] { + if (typeof plan !== "object" || plan === null) return []; + const diagnostics = Reflect.get(plan, "diagnostics"); + if (!Array.isArray(diagnostics)) return []; + return diagnostics.filter((diagnostic) => legacyIsLibraryDiagnostic(diagnostic)); +} + function legacyNormalizePgDeltaNextDiagnostics( diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], origin: LegacyPgDeltaNextDiagnosticOrigin, @@ -273,99 +294,6 @@ function legacySkippedStatementDiagnostics( })); } -function legacyIsPgDeltaNextParameterAclDiagnostic( - diagnostic: LegacyPgDeltaNextLibraryDiagnostic, -): boolean { - return diagnostic.code === "unmodeled_kind" && diagnostic.context?.["kind"] === "parameter ACL"; -} - -/** - * The parameter-ACL catalog is cluster-wide, so a co-located declarative shadow - * observes Supabase platform grants too. Keep strict coverage for every ACL - * other than the exact platform bootstrap grant while removing the aggregate - * diagnostic when that bootstrap grant is the only observed parameter ACL. - */ -export function legacyFilterPgDeltaNextPlatformParameterAclDiagnostics( - diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], - userOwnedParameterAcls: readonly string[], -): LegacyPgDeltaNextLibraryDiagnostic[] { - const names = [...new Set(userOwnedParameterAcls)].sort(); - const filtered: LegacyPgDeltaNextLibraryDiagnostic[] = []; - for (const diagnostic of diagnostics) { - if (!legacyIsPgDeltaNextParameterAclDiagnostic(diagnostic)) { - filtered.push(diagnostic); - continue; - } - if (names.length === 0) continue; - const samples = names.slice(0, 5); - const more = names.length > samples.length ? ", …" : ""; - filtered.push({ - ...diagnostic, - message: - `${names.length} unmodeled "parameter ACL" object${names.length === 1 ? "" : "s"} ` + - `not managed by this engine (e.g. ${samples.join(", ")}${more}) — ` + - "v1 detects but does not model this kind", - context: { kind: "parameter ACL", count: names.length, samples }, - }); - } - return filtered; -} - -interface LegacyPgDeltaNextParameterAclGrant { - readonly name: string; - readonly grantee: string; - readonly privilege: string; -} - -// Supabase's platform bootstrap grants these so privileged platform roles can -// manage the setting and the Realtime owner can replay routines whose proconfig -// contains `SET log_min_messages ...`. Parameter ACLs have cluster scope, so -// the grants are also visible from sibling shadow DBs. -const legacyPgDeltaNextPlatformParameterAcls = new Set([ - "log_min_messages\u0000supabase_admin\u0000ALTER SYSTEM", - "log_min_messages\u0000supabase_admin\u0000SET", - "log_min_messages\u0000supabase_realtime_admin\u0000SET", -]); - -function legacyPgDeltaNextParameterAclKey(grant: LegacyPgDeltaNextParameterAclGrant): string { - return `${grant.name}\u0000${grant.grantee}\u0000${grant.privilege}`; -} - -export function legacyPgDeltaNextUserOwnedParameterAcls( - grants: readonly LegacyPgDeltaNextParameterAclGrant[], -): string[] { - return [ - ...new Set( - grants - .filter( - (grant) => - !legacyPgDeltaNextPlatformParameterAcls.has(legacyPgDeltaNextParameterAclKey(grant)), - ) - .map((grant) => grant.name), - ), - ].sort(); -} - -async function legacyFilterPgDeltaNextPlatformDiagnostics( - pool: Pool, - diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], -): Promise[]> { - if (!diagnostics.some(legacyIsPgDeltaNextParameterAclDiagnostic)) return [...diagnostics]; - const result = await pool.query( - `SELECT DISTINCT pa.parname AS name, - COALESCE(grantee.rolname, 'PUBLIC') AS grantee, - acl.privilege_type AS privilege - FROM pg_parameter_acl pa - CROSS JOIN LATERAL aclexplode(pa.paracl) acl - LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee - ORDER BY pa.parname, grantee, privilege`, - ); - return legacyFilterPgDeltaNextPlatformParameterAclDiagnostics( - diagnostics, - legacyPgDeltaNextUserOwnedParameterAcls(result.rows), - ); -} - function legacyNormalizePgDeltaNextRenderedFiles( files: readonly LegacyPgDeltaNextLibraryRenderedFile[], ): LegacyPgDeltaNextRenderedFile[] { @@ -412,17 +340,22 @@ export function legacyPgDeltaNextProfile( return { ...supabaseProfile, policy }; } -const legacyPgDeltaNextHumanFormatOptions: SqlFormatOptions = { - keywordCase: "lower", +/** Human-readable SQL when `[experimental.pgdelta] format_options` is omitted. */ +export const legacyPgDeltaNextDefaultFormatOptions = { + keywordCase: "upper", + indent: 2, maxWidth: 180, -}; + commaStyle: "trailing", + alignColumns: true, + alignKeyValues: true, +} satisfies SqlFormatOptions; function legacyPgDeltaNextFormatOptions(raw: string | undefined): SqlFormatOptions | undefined { - if (raw === undefined || raw.trim().length === 0) return legacyPgDeltaNextHumanFormatOptions; + if (raw === undefined || raw.trim().length === 0) return legacyPgDeltaNextDefaultFormatOptions; const parsed: unknown = JSON.parse(raw); if (parsed === null) return undefined; if (typeof parsed !== "object" || Array.isArray(parsed)) { - return legacyPgDeltaNextHumanFormatOptions; + return legacyPgDeltaNextDefaultFormatOptions; } const value = (key: string): unknown => Reflect.get(parsed, key); const keywordCase = value("keywordCase"); @@ -435,7 +368,7 @@ function legacyPgDeltaNextFormatOptions(raw: string | undefined): SqlFormatOptio const preserveViewBodies = value("preserveViewBodies"); const preserveRuleBodies = value("preserveRuleBodies"); return { - ...legacyPgDeltaNextHumanFormatOptions, + ...legacyPgDeltaNextDefaultFormatOptions, ...(keywordCase === "upper" || keywordCase === "lower" || keywordCase === "preserve" ? { keywordCase } : {}), @@ -455,6 +388,14 @@ function legacyTerminatePgDeltaNextStatement(sql: string): string { return trimmed.endsWith(";") ? trimmed : `${trimmed};`; } +export function legacyFormatPgDeltaNextSql( + sql: string, + format: SqlFormatOptions | undefined, +): string { + if (format === undefined) return sql; + return `${formatSqlStatements([sql], format).map(legacyTerminatePgDeltaNextStatement).join("\n\n")}\n`; +} + function legacyFormatPgDeltaNextRenderedFiles( files: readonly LegacyPgDeltaNextLibraryRenderedFile[], format: SqlFormatOptions | undefined, @@ -462,9 +403,7 @@ function legacyFormatPgDeltaNextRenderedFiles( if (format === undefined) return files; return files.map((file) => ({ ...file, - contents: `${formatSqlStatements([file.contents], format) - .map(legacyTerminatePgDeltaNextStatement) - .join("\n\n")}\n`, + contents: legacyFormatPgDeltaNextSql(file.contents, format), })); } @@ -480,9 +419,15 @@ function legacyPgDeltaNextExportOptions(input: LegacyPgDeltaNextDeclarativeExpor function legacyPgDeltaNextPlanOptions(input: LegacyPgDeltaNextDeclarativePlanInput) { let manifest; if (input.manifest !== undefined) { - const { files, ...metadata } = input.manifest; - manifest = { ...metadata, ...(files !== undefined ? { files: [...files] } : {}) }; + const { files, loadOrder, ...metadata } = input.manifest; + manifest = { + ...metadata, + ...(files !== undefined ? { files: [...files] } : {}), + ...(loadOrder !== undefined ? { loadOrder: [...loadOrder] } : {}), + }; } + // Isolated load only. pg-delta's preflight derives scope/redactSecrets from + // the manifest and files — do not pin those here. return { profile: legacyPgDeltaNextProfile(input.schema), ...(manifest !== undefined ? { manifest } : {}), @@ -491,6 +436,7 @@ function legacyPgDeltaNextPlanOptions(input: LegacyPgDeltaNextDeclarativePlanInp seedAssumedSchemas: false, strictDataStatements: true, reorder: true, + connectionReuse: "reconnect-on-stuck" as const, }; } @@ -518,6 +464,7 @@ function legacyMakePgDeltaNextAdapter(generatedPlan); const diagnostics = [ ...legacyNormalizePgDeltaNextDiagnostics( source.diagnostics, @@ -529,6 +476,11 @@ function legacyMakePgDeltaNextAdapter(result.plan); const libraryDiagnostics = [ ...result.loadDiagnostics, ...result.targetDiagnostics, ...result.driftDiagnostics, + ...planDiagnostics, ]; return { changes: rendered.changes, @@ -615,6 +570,11 @@ function legacyMakePgDeltaNextAdapter[2], schema?: readonly string[], - ) => { - const resolved = await resolveProfile(pool, legacyPgDeltaNextProfile(schema), options); - return { - ...resolved, - extract: async ( - extractPool: Pool, - extractOptions?: Parameters[1], - ) => { - const result = await resolved.extract(extractPool, extractOptions); - return { - ...result, - diagnostics: await legacyFilterPgDeltaNextPlatformDiagnostics( - extractPool, - result.diagnostics, - ), - }; - }, - }; - }, + ) => resolveProfile(pool, legacyPgDeltaNextProfile(schema), options), plan, renderPlanFiles, - buildSchemaExport: async (pool: Pool, input: LegacyPgDeltaNextLibraryExportOptions) => { - const result = await buildSchemaExport(pool, input); - return { - ...result, - diagnostics: await legacyFilterPgDeltaNextPlatformDiagnostics(pool, result.diagnostics), - }; - }, - planSchemaFiles: async ( + buildSchemaExport, + planSchemaFiles: ( targetPool: Pool, shadowPool: Pool, files: readonly LegacyPgDeltaNextSqlFile[], input: LegacyPgDeltaNextLibraryPlanOptions, - ) => { - const result = await planSchemaFiles( + ) => + planSchemaFiles( targetPool, shadowPool, files.map((file) => ({ name: file.name, sql: file.sql })), input, - ); - const [loadDiagnostics, targetDiagnostics] = await Promise.all([ - legacyFilterPgDeltaNextPlatformDiagnostics(shadowPool, result.loadDiagnostics), - legacyFilterPgDeltaNextPlatformDiagnostics(targetPool, result.targetDiagnostics), - ]); - return { ...result, loadDiagnostics, targetDiagnostics }; - }, + ), serializeSnapshot, serializePlan, summarizeRemovals: legacySummarizePgDeltaNextRemovals, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts index d2c9cb6846..cce5e36e6a 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts @@ -28,6 +28,7 @@ export type LegacyPgDeltaNextDiagnosticOrigin = | "declarativeLoad" | "declarativeTarget" | "declarativeDrift" + | "plan" | "snapshot"; export interface LegacyPgDeltaNextDiagnostic { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts index c4c47df6fe..e3c5ab55c7 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts @@ -7,10 +7,10 @@ import { Pool } from "pg"; import { describe, expect } from "vitest"; import { + legacyFormatPgDeltaNextSql, legacyPgDeltaNextAdapterLayerFromLibraries, - legacyFilterPgDeltaNextPlatformParameterAclDiagnostics, + legacyPgDeltaNextDefaultFormatOptions, legacyPgDeltaNextProfile, - legacyPgDeltaNextUserOwnedParameterAcls, legacySummarizePgDeltaNextHazards, legacySummarizePgDeltaNextRemovals, type LegacyPgDeltaNextLibraries, @@ -300,64 +300,17 @@ describe("LegacyPgDeltaNextAdapter", () => { }); }); - it("filters platform parameter ACL coverage without hiding user-owned ACLs", () => { - const diagnostics = [ - { - origin: "declarativeLoad" as const, - code: "unmodeled_kind", - severity: "warning" as const, - message: "2 unmodeled parameter ACLs", - context: { - kind: "parameter ACL", - count: 2, - samples: ["log_min_messages", "work_mem"], - }, - }, - { - origin: "declarativeLoad" as const, - code: "unsupported_extension", - severity: "warning" as const, - message: "extension is externally managed", - }, - ]; - - expect(legacyFilterPgDeltaNextPlatformParameterAclDiagnostics(diagnostics, [])).toEqual([ - diagnostics[1], - ]); + it("pretty-prints SQL with the CLI default options", () => { expect( - legacyFilterPgDeltaNextPlatformParameterAclDiagnostics(diagnostics, ["work_mem"]), - ).toEqual([ - { - ...diagnostics[0], - message: - '1 unmodeled "parameter ACL" object not managed by this engine (e.g. work_mem) — v1 detects but does not model this kind', - context: { kind: "parameter ACL", count: 1, samples: ["work_mem"] }, - }, - diagnostics[1], - ]); - }); - - it("recognizes only the exact Supabase platform parameter grant tuples", () => { - expect( - legacyPgDeltaNextUserOwnedParameterAcls([ - { name: "log_min_messages", grantee: "supabase_admin", privilege: "SET" }, - { name: "log_min_messages", grantee: "app_user", privilege: "SET" }, - { name: "work_mem", grantee: "supabase_realtime_admin", privilege: "SET" }, - { name: "work_mem", grantee: "app_user", privilege: "SET" }, - ]), - ).toEqual(["log_min_messages", "work_mem"]); - expect( - legacyPgDeltaNextUserOwnedParameterAcls([ - { name: "log_min_messages", grantee: "supabase_admin", privilege: "ALTER SYSTEM" }, - { name: "log_min_messages", grantee: "supabase_admin", privilege: "SET" }, - { name: "log_min_messages", grantee: "supabase_realtime_admin", privilege: "SET" }, - ]), - ).toEqual([]); - expect( - legacyPgDeltaNextUserOwnedParameterAcls([ - { name: "log_min_messages", grantee: "supabase_realtime_admin", privilege: "ALTER SYSTEM" }, - ]), - ).toEqual(["log_min_messages"]); + legacyFormatPgDeltaNextSql( + "create table public.widgets (id integer, display_name text);", + legacyPgDeltaNextDefaultFormatOptions, + ), + ).toBe(`CREATE TABLE public.widgets ( + id integer, + display_name text +); +`); }); it("renders selected-schema state without leaking other user or platform objects", () => { @@ -543,7 +496,7 @@ describe("LegacyPgDeltaNextAdapter", () => { pool: targetPool, }); expect(state.exportInputs[1]).toMatchObject({ - format: { keywordCase: "lower", maxWidth: 180 }, + format: legacyPgDeltaNextDefaultFormatOptions, }); const planned = yield* adapter.planDeclarativeSchema({ @@ -554,15 +507,24 @@ describe("LegacyPgDeltaNextAdapter", () => { allowSameDatabaseIdentity: true, debug: true, formatOptions: "null", + manifest: { + redactSecrets: true, + scope: "database", + loadOrder: ["public/tables/items.sql"], + }, }); expect(state.declarativeInputs).toHaveLength(1); expect(state.declarativeInputs[0]).toMatchObject({ reorder: true, + connectionReuse: "reconnect-on-stuck", isolatedShadow: true, allowSameDatabaseIdentity: true, seedAssumedSchemas: false, strictDataStatements: true, + manifest: { loadOrder: ["public/tables/items.sql"] }, }); + expect(state.declarativeInputs[0]).not.toHaveProperty("scope"); + expect(state.declarativeInputs[0]).not.toHaveProperty("redactSecrets"); expect(planned.diagnostics.map((diagnostic) => diagnostic.origin)).toEqual([ "declarativeLoad", "declarativeTarget", @@ -612,6 +574,58 @@ describe("LegacyPgDeltaNextAdapter", () => { }, ); + it.effect("forwards plan-time vault_presence into the diagnostic report", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const layer = legacyPgDeltaNextAdapterLayerFromLibraries({ + ...unusedLibraries, + resolveProfile: async () => ({ + id: "supabase", + planOptions: {}, + extract: async () => ({ + factBase: "facts", + pgVersion: "17.6", + diagnostics: [], + }), + }), + plan: () => ({ + source: "s", + desired: "d", + diagnostics: [fakeDiagnostic("vault_presence", "vault")], + }), + renderPlanFiles: () => ({ changes: false, files: [] }), + encodeSubject: (subject) => + typeof subject === "object" && subject !== null && "id" in subject + ? `subject:${String(Reflect.get(subject, "id"))}` + : String(subject), + summarizeHazards: () => ({ + actions: [], + dataLoss: [], + coverage: ["vault_presence"], + kinds: ["vault_presence"], + }), + }); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: false, + debug: false, + }); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + origin: "plan", + code: "vault_presence", + subject: "subject:vault", + }), + ]); + expect(result.hazards.kinds).toEqual(["vault_presence"]); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + it.effect("preserves shadow-load diagnostics in the actionable error", () => { const targetPool = new Pool(); const shadowPool = new Pool(); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts index 8319962425..8ff3ae3882 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -9,10 +9,7 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - LegacyDbConnection, - type LegacyDbSession, -} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { @@ -21,7 +18,6 @@ import { } from "../../../shared/db-bootstrap/local-container-inputs.ts"; import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; import { - legacyConnectShadowDatabase, legacyCreateShadowDatabase, legacyMigrateNextShadowDatabase, legacyRemoveShadowDatabase, @@ -79,25 +75,6 @@ interface NativeShadowBase { readonly image: string; } -/** - * Removes extensions that the legacy PG14 platform baseline installs implicitly - * so the declarative shadow reflects only extension declarations in schema files. - * `pgjwt` has a hard extension dependency on `pgcrypto`, and `storage.objects.id` - * depends on `uuid-ossp`, so both dependencies must be detached before the - * user-manageable extensions can be dropped with the default RESTRICT behavior. - */ -export const legacyPreparePgDeltaNextDeclarativeBaseline = Effect.fnUntraced(function* ( - session: Pick, - majorVersion: number, -) { - if (majorVersion === 14) { - yield* session.exec("ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT"); - yield* session.exec("DROP EXTENSION IF EXISTS pgjwt"); - } - yield* session.exec("DROP EXTENSION IF EXISTS pgcrypto"); - yield* session.exec('DROP EXTENSION IF EXISTS "uuid-ossp"'); -}); - const setupRunInput = (input: NativeShadowInput, handle: LegacyShadowDatabaseHandle) => ({ fs: input.base.fs, path: input.base.path, @@ -226,15 +203,6 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( }); const setup = setupRunInput(input, handle); yield* legacySetupShadowDatabase(input.spawner, setup, { webhooks: "disabled" }); - yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* legacyConnectShadowDatabase(setup.connConfig); - yield* legacyPreparePgDeltaNextDeclarativeBaseline( - session, - input.base.setup.majorVersion, - ); - }), - ); return legacyToPostgresURL(setup.connConfig); }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.unit.test.ts deleted file mode 100644 index aa40d07057..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.unit.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { it } from "@effect/vitest"; -import { Effect } from "effect"; -import { describe, expect } from "vitest"; - -import { legacyPreparePgDeltaNextDeclarativeBaseline } from "./legacy-pgdelta-next-shadow.layer.ts"; - -function recordingSession() { - const statements: string[] = []; - return { - statements, - session: { - exec: (sql: string) => - Effect.sync(() => { - statements.push(sql); - }), - }, - }; -} - -describe("legacyPreparePgDeltaNextDeclarativeBaseline", () => { - it.effect("detaches the PG14 platform dependencies before dropping extensions", () => { - const { session, statements } = recordingSession(); - return Effect.gen(function* () { - yield* legacyPreparePgDeltaNextDeclarativeBaseline(session, 14); - expect(statements).toEqual([ - "ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT", - "DROP EXTENSION IF EXISTS pgjwt", - "DROP EXTENSION IF EXISTS pgcrypto", - 'DROP EXTENSION IF EXISTS "uuid-ossp"', - ]); - }); - }); - - it.effect("does not modify PG15+ platform objects before dropping extensions", () => { - const { session, statements } = recordingSession(); - return Effect.gen(function* () { - yield* legacyPreparePgDeltaNextDeclarativeBaseline(session, 17); - expect(statements).toEqual([ - "DROP EXTENSION IF EXISTS pgcrypto", - 'DROP EXTENSION IF EXISTS "uuid-ossp"', - ]); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca416dc003..58d91cf5dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,8 +139,8 @@ importers: specifier: workspace:* version: link:../../packages/config '@supabase/pg-delta': - specifier: 1.0.0-alpha.42 - version: 1.0.0-alpha.42(@supabase/pg-topo@1.0.0-alpha.5) + specifier: 1.0.0-alpha.46 + version: 1.0.0-alpha.46(@supabase/pg-topo@1.0.0-alpha.5) '@supabase/pg-topo': specifier: 1.0.0-alpha.5 version: 1.0.0-alpha.5 @@ -2763,8 +2763,8 @@ packages: resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} engines: {node: '>=22.0.0'} - '@supabase/pg-delta@1.0.0-alpha.42': - resolution: {integrity: sha512-E1t30VEBu4ZZF6fK90iVfBT3AJTXM70XOfeMnJQ0vh9kRSuVOnVoYXjWh9/Nf/faJ2+kOCRNGKiUDQaTAbzTzQ==} + '@supabase/pg-delta@1.0.0-alpha.46': + resolution: {integrity: sha512-PaziTZjZk+zMw+wL2iBR0kJB1rOMGPCanYYYjb2pxwINCAr+XMNjnbWxTDMVZjNhPdOYLe34ocpO2lHLv+LK1A==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -8817,7 +8817,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/pg-delta@1.0.0-alpha.42(@supabase/pg-topo@1.0.0-alpha.5)': + '@supabase/pg-delta@1.0.0-alpha.46(@supabase/pg-topo@1.0.0-alpha.5)': dependencies: debug: 4.4.3(supports-color@7.2.0) pg: 8.23.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 45f3012f29..4a36180760 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,7 +45,7 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-rc.111" - "@effect/sql-pg@4.0.0-rc.111" - "@effect/vitest@4.0.0-rc.111" - - "@supabase/pg-delta@1.0.0-alpha.42" + - "@supabase/pg-delta@1.0.0-alpha.46" - "@supabase/pg-topo@1.0.0-alpha.5" - "@types/bun@1.4.0" - "bun-types@1.4.0" From 1bf8ecd2ae818cebaca86e886056b5e9df9101ad Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 24 Aug 2026 08:51:55 +0000 Subject: [PATCH 30/63] test(cli): collocate live e2e coverage (#6294) ## Summary Collocate remote-platform golden-path tests beside the commands they cover in apps/cli, while keeping Docker-stack-only scenarios as ordinary e2e tests and leaving the apps/cli-e2e replay and recording suite unchanged. Use one serial live Vitest project with a single extended test fixture. Global setup requires a Management API URL and access token, provisions one disposable project through the typed Effect API client, waits with bounded retry semantics, derives the platform tenant host from project metadata, and shares cli plus project fixtures with every test. Explicit live runs fail fast when configuration is missing; there are no capability gates or runtime skips. Keep live coverage intentionally narrow: one representative golden path per covered command. Setup and teardown may invoke other commands, but assertions stay focused on the command under test. Exact owned resources are cleaned even after ambiguous command results, and target plus cleanup failures are preserved. Move the eight local Docker-stack suites to e2e naming, including functions dev synchronization on observable reload completion. The standalone live workflow retains Docker preflight, serial execution, one attempt, a 20-minute limit, and scoped project sweeping. --- .github/workflows/dispatch-cli-e2e-ci.yml | 9 +- .github/workflows/live-e2e.yml | 33 +- CONTRIBUTING.md | 129 ++-- apps/cli-e2e/.env.example | 25 +- apps/cli-e2e/AGENTS.md | 25 +- .../fixtures/live/functions-config.toml | 19 - .../live/functions-project/assets/badge.svg | 3 - .../functions/_shared/greet.ts | 1 - .../functions/deploy-e2e-basic/deno.json | 3 - .../functions/deploy-e2e-basic/index.ts | 1 - .../deploy-e2e-custom-entry/deno.json | 3 - .../deploy-e2e-custom-entry/handler.ts | 3 - .../deploy-e2e-deno-jsonc/deno.jsonc | 6 - .../functions/deploy-e2e-deno-jsonc/index.ts | 5 - .../deploy-e2e-deprecated-map/import_map.json | 5 - .../deploy-e2e-deprecated-map/index.ts | 5 - .../deploy-e2e-dynamic-import/deno.json | 3 - .../deploy-e2e-dynamic-import/index.ts | 4 - .../deploy-e2e-dynamic-import/lazy.ts | 1 - .../functions/deploy-e2e-jsr/deno.json | 3 - .../functions/deploy-e2e-jsr/index.ts | 5 - .../deploy-e2e-jwt-required/deno.json | 3 - .../deploy-e2e-jwt-required/index.ts | 1 - .../deploy-e2e-local-imports/deno.json | 3 - .../deploy-e2e-local-imports/helpers.ts | 1 - .../deploy-e2e-local-imports/index.ts | 6 - .../functions/deploy-e2e-mode-api/deno.json | 3 - .../functions/deploy-e2e-mode-api/index.ts | 1 - .../deploy-e2e-mode-default/deno.json | 3 - .../deploy-e2e-mode-default/index.ts | 1 - .../deploy-e2e-mode-docker/deno.json | 3 - .../functions/deploy-e2e-mode-docker/index.ts | 1 - .../functions/deploy-e2e-no-jwt/deno.json | 3 - .../functions/deploy-e2e-no-jwt/index.ts | 1 - .../functions/deploy-e2e-npm/deno.json | 3 - .../functions/deploy-e2e-npm/index.ts | 10 - .../deploy-e2e-package-json/index.ts | 1 - .../deploy-e2e-package-json/package.json | 4 - .../deploy-e2e-remote-only/deno.json | 3 - .../functions/deploy-e2e-remote-only/index.ts | 1 - .../functions/deploy-e2e-root-map/deno.json | 3 - .../functions/deploy-e2e-root-map/index.ts | 5 - .../functions/deploy-e2e-scoped-map/deno.json | 5 - .../functions/deploy-e2e-scoped-map/index.ts | 5 - .../deploy-e2e-static-asset/assets/badge.svg | 3 - .../deploy-e2e-static-asset/deno.json | 3 - .../deploy-e2e-static-asset/index.ts | 10 - .../deploy-e2e-static-in-fn/deno.json | 3 - .../deploy-e2e-static-in-fn/index.ts | 8 - .../deploy-e2e-static-in-fn/static/note.txt | 1 - .../live/functions-project/import_map.json | 5 - apps/cli-e2e/package.json | 4 +- apps/cli-e2e/src/tests/env.ts | 54 +- .../src/tests/live/branches.live.e2e.test.ts | 49 -- .../src/tests/live/database.live.e2e.test.ts | 32 - .../live/db-reset-start.live.e2e.test.ts | 88 --- .../src/tests/live/db-sync.live.e2e.test.ts | 47 -- .../live/functions-deploy.live.e2e.test.ts | 66 -- .../live/functions-lifecycle.live.e2e.test.ts | 73 --- .../src/tests/live/gen-types.live.e2e.test.ts | 13 - apps/cli-e2e/src/tests/live/invoke.ts | 47 -- .../src/tests/live/link.live.e2e.test.ts | 21 - apps/cli-e2e/src/tests/live/live-context.ts | 122 ---- .../src/tests/live/projects.live.e2e.test.ts | 37 -- .../src/tests/live/secrets.live.e2e.test.ts | 48 -- .../src/tests/live/storage.live.e2e.test.ts | 45 -- apps/cli-e2e/tests/live-setup.ts | 106 ---- apps/cli-e2e/tests/provided-context.ts | 20 +- apps/cli-e2e/tests/staging-project.ts | 175 +----- apps/cli-e2e/vitest.config.ts | 5 +- apps/cli-e2e/vitest.live.config.ts | 21 - apps/cli/AGENTS.md | 53 +- apps/cli/live.env.example | 17 + apps/cli/package.json | 1 + .../cli}/scripts/sweep-live-projects.sh | 8 +- .../branches/create/create.live.test.ts | 40 ++ .../branches/delete/delete.live.test.ts | 47 ++ .../commands/branches/list/list.live.test.ts | 64 +- .../db/diff/diff.declarative.e2e.test.ts | 115 ++++ .../legacy/commands/db/diff/diff.live.test.ts | 221 ------- .../legacy/commands/db/dump/dump.live.test.ts | 52 +- .../legacy/commands/db/pull/pull.live.test.ts | 125 ++-- .../legacy/commands/db/push/push.live.test.ts | 34 + .../commands/db/reset/reset.live.test.ts | 34 + .../schema/declarative/sync/sync.e2e.test.ts | 172 ++++++ .../shared/legacy-pgdelta-next.live.test.ts | 138 ----- .../commands/db/start/start.e2e.test.ts | 41 ++ .../functions/delete/delete.live.test.ts | 58 ++ .../functions/deploy/deploy.live.test.ts | 58 ++ .../commands/functions/list/list.live.test.ts | 60 +- .../commands/gen/types/types.live.test.ts | 9 + .../inspect/db/db-stats/db-stats.live.test.ts | 9 + .../legacy/commands/link/link.live.test.ts | 12 + .../migration/fetch/fetch.live.test.ts | 145 ++--- .../commands/migration/list/list.live.test.ts | 56 +- .../commands/orgs/list/list.live.test.ts | 59 +- .../projects/api-keys/api-keys.live.test.ts | 19 + .../commands/projects/list/list.live.test.ts | 50 +- .../commands/secrets/list/list.live.test.ts | 50 ++ .../commands/secrets/set/set.live.test.ts | 44 ++ .../commands/secrets/unset/unset.live.test.ts | 47 ++ ...ve.test.ts => start.lifecycle.e2e.test.ts} | 84 +-- .../legacy/commands/status/status.e2e.test.ts | 77 +++ .../commands/status/status.live.test.ts | 54 -- .../src/legacy/commands/stop/stop.e2e.test.ts | 175 ++++++ .../legacy/commands/stop/stop.live.test.ts | 134 ---- .../commands/storage/cp/cp.live.test.ts | 49 ++ .../commands/storage/ls/ls.live.test.ts | 57 ++ .../commands/storage/rm/rm.live.test.ts | 51 ++ .../commands/functions/dev/dev.e2e.test.ts | 189 ++++++ .../commands/functions/dev/dev.live.test.ts | 179 ------ .../src/next/commands/start/start.e2e.test.ts | 101 +++ .../next/commands/start/start.live.test.ts | 89 --- .../runtime/stack-e2e-cleanup.unit.test.ts | 173 ++++-- apps/cli/tests/helpers/cli.ts | 67 +- apps/cli/tests/helpers/live-env.ts | 159 ++--- apps/cli/tests/helpers/live-env.unit.test.ts | 44 ++ apps/cli/tests/helpers/live-project.ts | 584 ++++++++++++++++++ .../tests/helpers/live-project.unit.test.ts | 185 ++++++ .../tests/helpers/live-provided-context.ts | 18 + apps/cli/tests/helpers/live.ts | 238 ++++--- apps/cli/tests/helpers/live.unit.test.ts | 33 + apps/cli/tests/helpers/stack-e2e-cleanup.ts | 73 ++- apps/cli/tests/live-global-setup.ts | 65 +- apps/cli/vitest.config.ts | 6 +- .../0013-live-e2e-bypasses-replay-server.md | 169 ++--- docs/adr/README.md | 34 +- 127 files changed, 3367 insertions(+), 3025 deletions(-) delete mode 100644 apps/cli-e2e/fixtures/live/functions-config.toml delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/deno.json delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/index.ts delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt delete mode 100644 apps/cli-e2e/fixtures/live/functions-project/import_map.json delete mode 100644 apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/database.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/invoke.ts delete mode 100644 apps/cli-e2e/src/tests/live/link.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/live-context.ts delete mode 100644 apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts delete mode 100644 apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts delete mode 100644 apps/cli-e2e/tests/live-setup.ts delete mode 100644 apps/cli-e2e/vitest.live.config.ts create mode 100644 apps/cli/live.env.example rename {.github => apps/cli}/scripts/sweep-live-projects.sh (80%) create mode 100644 apps/cli/src/legacy/commands/branches/create/create.live.test.ts create mode 100644 apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/diff/diff.live.test.ts create mode 100644 apps/cli/src/legacy/commands/db/push/push.live.test.ts create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.live.test.ts create mode 100644 apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts create mode 100644 apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/types/types.live.test.ts create mode 100644 apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts create mode 100644 apps/cli/src/legacy/commands/link/link.live.test.ts create mode 100644 apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts create mode 100644 apps/cli/src/legacy/commands/secrets/list/list.live.test.ts create mode 100644 apps/cli/src/legacy/commands/secrets/set/set.live.test.ts create mode 100644 apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts rename apps/cli/src/legacy/commands/start/{start.live.test.ts => start.lifecycle.e2e.test.ts} (80%) create mode 100644 apps/cli/src/legacy/commands/status/status.e2e.test.ts delete mode 100644 apps/cli/src/legacy/commands/status/status.live.test.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.e2e.test.ts delete mode 100644 apps/cli/src/legacy/commands/stop/stop.live.test.ts create mode 100644 apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts create mode 100644 apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts create mode 100644 apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts create mode 100644 apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts delete mode 100644 apps/cli/src/next/commands/functions/dev/dev.live.test.ts create mode 100644 apps/cli/src/next/commands/start/start.e2e.test.ts delete mode 100644 apps/cli/src/next/commands/start/start.live.test.ts create mode 100644 apps/cli/tests/helpers/live-env.unit.test.ts create mode 100644 apps/cli/tests/helpers/live-project.ts create mode 100644 apps/cli/tests/helpers/live-project.unit.test.ts create mode 100644 apps/cli/tests/helpers/live-provided-context.ts create mode 100644 apps/cli/tests/helpers/live.unit.test.ts diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml index 4dfecad569..109b85cf49 100644 --- a/.github/workflows/dispatch-cli-e2e-ci.yml +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -3,10 +3,11 @@ name: Dispatch cli-e2e-ci # Asks the supabase/cli-e2e-ci harness to run the cli `test:live` suite against # a full supabox stack, built from THIS PR's head commit (CLI-1825 / CLI-1831). # -# This is distinct from `live-e2e.yml`, which runs the cli-e2e package against -# real staging (api.supabase.green). Here the suite runs against a local supabox -# stack stood up inside the private cli-e2e-ci repo; we only fire the trigger and -# pass our head SHA — cli-e2e-ci checks that SHA out into its `cli` submodule. +# This is distinct from `live-e2e.yml`, which runs the collocated live suite +# against managed staging (api.supabase.green). Here the same `apps/cli` suite +# runs against a local Supabox stack stood up inside the private cli-e2e-ci repo; +# we only fire the trigger and pass our head SHA — cli-e2e-ci checks that SHA out +# into its `cli` submodule. # # Opt-in by label to keep the expensive full-stack run off every PR: add the # `run-live-e2e-ci` label (re-dispatches on each subsequent push while labeled). diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 2d1aff3a99..8ccd621401 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,7 +1,7 @@ name: Live E2E -# Live e2e suite (ADR-0013). Runs the real CLI against the real staging -# Management API + Docker bundler, then invokes the deployed functions over HTTP. +# Live e2e suite. Runs the collocated `apps/cli` tests against the real staging +# Management API + Docker bundler, then invokes deployed functions over HTTP. # # Non-blocking by construction: this is a standalone workflow, NOT part of the # required-checks set, and it never runs on the default PR path of test.yml. @@ -99,10 +99,8 @@ jobs: # Non-secret config is job-level; the staging token is scoped to only the two # steps that need it (run + cleanup) so build/checkout/docker never see it. env: - CLI_E2E_MODE: live - CLI_E2E_TARGET_ENV: staging - CLI_E2E_API_URL: https://api.supabase.green - CLI_E2E_PROJECT_HOST: supabase.red + SUPABASE_LIVE_API_URL: https://api.supabase.green + SUPABASE_LIVE_PROJECT_NAME: supabase-cli-live-${{ matrix.target }} CLI_HARNESS_TARGET: ${{ matrix.target }} steps: - name: Checkout @@ -131,26 +129,11 @@ jobs: - name: Docker preflight run: docker info - - name: Run live e2e (retry up to 3x) + - name: Run live e2e + timeout-minutes: 20 env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - run: | - PREFIX="cli-e2e-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" - # GitHub runs this step as `bash -e`; use `if cmd; then` (errexit-exempt) - # so a failing attempt does not abort the step before the retry. - for attempt in 1 2 3; do - echo "::group::live e2e attempt ${attempt}" - if [ "$attempt" -gt 1 ]; then - bash .github/scripts/sweep-live-projects.sh "$PREFIX" || true - fi - if pnpm --filter @supabase/cli-e2e test:e2e:live; then - echo "::endgroup::" - exit 0 - fi - echo "::endgroup::" - echo "attempt ${attempt} failed" - done - exit 1 + run: pnpm --filter supabase test:live # Backstop: delete any project this job created that survived a crash. # The script exits non-zero (failing this step) if any delete failed. @@ -158,7 +141,7 @@ jobs: if: always() env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - run: bash .github/scripts/sweep-live-projects.sh "cli-e2e-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" + run: bash apps/cli/scripts/sweep-live-projects.sh "supabase-cli-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" # Record that this beta tested green so the next scheduled run skips it. Needs # the whole matrix: the marker is saved only if the ts-legacy leg passed (a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ea9a7eac9..fe01bcedb5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,13 +52,13 @@ mise install `mise install` resolves the versions this repo expects from a handful of files, rather than hardcoding them all in one place: -| Tool | Version source | -| --- | --- | -| Bun | `.bun-version` | -| Node.js | `devEngines.runtime` field in `package.json` | -| pnpm | `packageManager` field in `package.json` | -| Go | `mise.toml` | -| golangci-lint | `mise.toml` | +| Tool | Version source | +| ------------- | -------------------------------------------- | +| Bun | `.bun-version` | +| Node.js | `devEngines.runtime` field in `package.json` | +| pnpm | `packageManager` field in `package.json` | +| Go | `mise.toml` | +| golangci-lint | `mise.toml` | The Go and golangci-lint entries in `mise.toml` are intentionally temporary while the Go CLI remains in the repo. The canonical Go module metadata still lives in `apps/cli-go/go.mod`; keep the `mise.toml` entries aligned only until the Go code is removed. @@ -105,28 +105,28 @@ That pulls `.repos/effect/`, which is the local source of truth for Effect v4 AP ## Apps -| Workspace | Purpose | -| --- | --- | -| `apps/cli` | Main `supabase` package. Contains command handlers, runtime services, auth, output, telemetry, and docs generation scripts. | +| Workspace | Purpose | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/cli` | Main `supabase` package. Contains command handlers, runtime services, auth, output, telemetry, and docs generation scripts. | | `apps/cli-e2e` | Compatibility e2e test suite. Record-and-replay harness for testing the TS Legacy port against real Supabase Management API responses. | -| `apps/docs` | Internal docs site built with Next.js and generated from the CLI docs sources. | +| `apps/docs` | Internal docs site built with Next.js and generated from the CLI docs sources. | ## Packages -| Workspace | Purpose | -| --- | --- | -| `packages/api` | Auto-generated TypeScript client for the Supabase Management API. | -| `packages/cli-test-helpers` | CLI test harness library — `createHarness`/`exec` API for spawning TS Legacy and TS Next CLI subprocesses in tests. | -| `packages/config` | JSON Schema and generated TypeScript types for Supabase configuration. | -| `packages/process-compose` | TypeScript/Bun port of `process-compose` used for multi-service orchestration. | -| `packages/stack` | Programmatic local Supabase stack used by the CLI and other tooling. | -| `packages/cli-darwin-arm64` | Published native CLI binary wrapper for macOS arm64. | -| `packages/cli-darwin-x64` | Published native CLI binary wrapper for macOS x64. | -| `packages/cli-linux-arm64` | Published native CLI binary wrapper for Linux arm64 (glibc). | -| `packages/cli-linux-arm64-musl` | Published native CLI binary wrapper for Linux arm64 (musl). | -| `packages/cli-linux-x64` | Published native CLI binary wrapper for Linux x64 (glibc). | -| `packages/cli-linux-x64-musl` | Published native CLI binary wrapper for Linux x64 (musl). | -| `packages/cli-windows-x64` | Published native CLI binary wrapper for Windows x64. | +| Workspace | Purpose | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `packages/api` | Auto-generated TypeScript client for the Supabase Management API. | +| `packages/cli-test-helpers` | CLI test harness library — `createHarness`/`exec` API for spawning TS Legacy and TS Next CLI subprocesses in tests. | +| `packages/config` | JSON Schema and generated TypeScript types for Supabase configuration. | +| `packages/process-compose` | TypeScript/Bun port of `process-compose` used for multi-service orchestration. | +| `packages/stack` | Programmatic local Supabase stack used by the CLI and other tooling. | +| `packages/cli-darwin-arm64` | Published native CLI binary wrapper for macOS arm64. | +| `packages/cli-darwin-x64` | Published native CLI binary wrapper for macOS x64. | +| `packages/cli-linux-arm64` | Published native CLI binary wrapper for Linux arm64 (glibc). | +| `packages/cli-linux-arm64-musl` | Published native CLI binary wrapper for Linux arm64 (musl). | +| `packages/cli-linux-x64` | Published native CLI binary wrapper for Linux x64 (glibc). | +| `packages/cli-linux-x64-musl` | Published native CLI binary wrapper for Linux x64 (musl). | +| `packages/cli-windows-x64` | Published native CLI binary wrapper for Windows x64. | ## Working In The Monorepo @@ -143,22 +143,22 @@ pnpm run fix:all # run all fixers across every project All standard TypeScript workspaces (`apps/cli`, `packages/api`, `packages/config`, `packages/process-compose`, `packages/stack`) expose the following scripts: -| Script | What it does | -|--------|--------------| -| `test` | Run the full test suite (unit + integration + e2e) | -| `test:core` | Run unit and integration tests | -| `test:unit` | Run unit tests _(inferred by Nx plugin)_ | -| `test:integration` | Run integration tests _(inferred by Nx plugin)_ | -| `test:e2e` | Run end-to-end tests _(inferred by Nx plugin)_ | -| `check:all` | Run all check targets for this project | -| `fix:all` | Run all fix targets for this project | -| `types:check` | Type-check with `tsc --noEmit` _(inferred by Nx plugin)_ | -| `lint:check` | Check for lint errors with `oxlint` _(inferred by Nx plugin)_ | -| `lint:fix` | Auto-fix lint errors _(inferred by Nx plugin)_ | -| `fmt:check` | Check formatting with `oxfmt --check` _(inferred by Nx plugin)_ | -| `fmt:fix` | Auto-fix formatting _(inferred by Nx plugin)_ | -| `knip:check` | Find unused exports and dependencies with `knip-bun` _(inferred by Nx plugin)_ | -| `knip:fix` | Auto-remove unused exports and dependencies _(inferred by Nx plugin)_ | +| Script | What it does | +| ------------------ | ------------------------------------------------------------------------------ | +| `test` | Run the full test suite (unit + integration + e2e) | +| `test:core` | Run unit and integration tests | +| `test:unit` | Run unit tests _(inferred by Nx plugin)_ | +| `test:integration` | Run integration tests _(inferred by Nx plugin)_ | +| `test:e2e` | Run end-to-end tests _(inferred by Nx plugin)_ | +| `check:all` | Run all check targets for this project | +| `fix:all` | Run all fix targets for this project | +| `types:check` | Type-check with `tsc --noEmit` _(inferred by Nx plugin)_ | +| `lint:check` | Check for lint errors with `oxlint` _(inferred by Nx plugin)_ | +| `lint:fix` | Auto-fix lint errors _(inferred by Nx plugin)_ | +| `fmt:check` | Check formatting with `oxfmt --check` _(inferred by Nx plugin)_ | +| `fmt:fix` | Auto-fix formatting _(inferred by Nx plugin)_ | +| `knip:check` | Find unused exports and dependencies with `knip-bun` _(inferred by Nx plugin)_ | +| `knip:fix` | Auto-remove unused exports and dependencies _(inferred by Nx plugin)_ | The inferred scripts (`test:unit`, `test:integration`, `test:e2e`, `types:check`, `lint:*`, `fmt:*`, `knip:*`) are not declared in `package.json` — they are injected by local Nx plugins in `tools/nx-plugins/`. They are fully cached and can be discovered via `nx show project `. @@ -176,18 +176,41 @@ pnpm run check:all ## E2E Compatibility Test Suite -`apps/cli-e2e` implements a record-and-replay test harness for testing the TypeScript Legacy CLI (`ts-legacy`, the only shipped CLI shell) against real Supabase Management API responses without hitting staging on every run. It still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for this suite, but the suite itself no longer compares Go and TS output — that go-target parity harness was retired once the legacy port and the CLI-1970 Go binary trim landed. +`apps/cli-e2e` implements the replay-and-record compatibility harness for the TypeScript Legacy CLI (`ts-legacy`, the only shipped CLI shell). Live tests are owned by `apps/cli` and run from the command they cover. The CLI still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for these suites, but there is no Go-vs-TypeScript parity runner. ### Architecture -Fixtures are recorded by running `ts-legacy` against the real Supabase staging API and capturing the request/response pairs. Every other run replays those committed fixtures against the same CLI, so tests are fast and deterministic with no network access. +Replay fixtures are recorded by running `ts-legacy` against the real Supabase staging API and capturing request/response pairs. Replay runs serve those committed fixtures back to the same CLI, so compatibility tests are fast and deterministic with no network access. The replay/record suite remains entirely under `apps/cli-e2e`. -The harness works in two modes: +The replay/record harness has two modes: -| Mode | When | What it does | -|------|------|-------------| +| Mode | When | What it does | +| -------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------- | | **Replay** (default) | Every PR / local dev | Loads committed fixtures; serves recorded responses to the CLI subprocess. Fast and deterministic — no network access. | -| **Record** | `RECORD=true` | Proxies CLI traffic to staging and captures request/response pairs as fixture files. | +| **Record** | `RECORD=true` | Proxies CLI traffic to staging and captures request/response pairs as fixture files. | + +### Live remote-project coverage + +The live suite lives in `apps/cli/src/**` as collocated `*.live.test.ts` files and runs in the CLI package's separate, serial `live` Vitest project. Global setup requires `SUPABASE_LIVE_API_URL` and `SUPABASE_ACCESS_TOKEN`, then provisions one uniquely named project through the typed Management API client, waits for it to become healthy, creates the shared storage fixture, and writes a temporary YAML profile. Every live subprocess receives that profile, so the same contract works with Supabox, a Docker-hosted API platform, or staging by changing only the URL and token. Teardown always removes the temporary profile and deletes the exact owned project unless `SUPABASE_LIVE_KEEP_PROJECT=1` is set. + +The configured URL is the Management API endpoint. Tenant data-plane URLs keep +the CLI profile contract (`https://.`) using the host derived +from the provisioned project's database metadata. + +Live coverage is smoke coverage, not an exhaustive command matrix. Add one representative golden-path test for each user-facing command, colocated beside that command. A live test should assert one target command; setup and teardown may invoke other commands when they prepare or clean up state, but those commands are not asserted in that test. Keep validation, formatting, fallback, error, and matrix details in integration tests unless the remote/runtime boundary itself is the behavior under test. See [ADR 0013](docs/adr/0013-live-e2e-bypasses-replay-server.md) and [`apps/cli/live.env.example`](apps/cli/live.env.example). + +To run the live suite locally, copy [`apps/cli/live.env.example`](apps/cli/live.env.example), set the API URL and access token for the target platform, and run the Nx target from the repository root. The target's build dependency prepares the CLI artifacts before Vitest starts: + +```sh +pnpm exec nx run supabase:test:live +``` + +Optional `SUPABASE_LIVE_ORG_ID`, `SUPABASE_LIVE_REGION`, and +`SUPABASE_LIVE_PROJECT_NAME` values select provisioning details. Set +`SUPABASE_LIVE_KEEP_PROJECT=1` only when debugging a failed run; the temporary +profile is still cleaned up. + +Live CI is manual or daily scheduled and is not PR-blocking; run it manually on a PR branch when you need pre-merge remote coverage. ### Running the tests @@ -303,13 +326,13 @@ supabase --version ### Troubleshooting -| Problem | Fix | -|---------|-----| -| `Error: Something is already running on port 4873` | Kill the leftover Verdaccio process (`lsof -ti:4873 \| xargs kill`) and retry | -| `go not found in PATH` (legacy only) | Install Go from https://go.dev/dl/ | -| `Error: Go CLI source not found` (legacy only) | Run `pnpm repos:install` to clone `apps/cli-go` | +| Problem | Fix | +| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Error: Something is already running on port 4873` | Kill the leftover Verdaccio process (`lsof -ti:4873 \| xargs kill`) and retry | +| `go not found in PATH` (legacy only) | Install Go from https://go.dev/dl/ | +| `Error: Go CLI source not found` (legacy only) | Run `pnpm repos:install` to clone `apps/cli-go` | | `npm` / `pnpm` tries to fetch from `localhost:4873` when no registry is running | Stale global registry override left behind by an older version of `local-registry.ts` (the current script never modifies global config). Run `npm config delete registry` and `pnpm config delete registry`. Note that pnpm stores the override in its own global config (`~/Library/Preferences/pnpm/auth.ini` on macOS, `~/.config/pnpm/` on Linux), not `~/.npmrc` — check there if the delete command fails | -| `npx` resolves from npm instead of local | Pass `--registry http://localhost:4873` explicitly to `npx` / `npm install` | +| `npx` resolves from npm instead of local | Pass `--registry http://localhost:4873` explicitly to `npx` / `npm install` | ## Using Nx diff --git a/apps/cli-e2e/.env.example b/apps/cli-e2e/.env.example index 24e2ff10e9..77de786a27 100644 --- a/apps/cli-e2e/.env.example +++ b/apps/cli-e2e/.env.example @@ -1,18 +1,14 @@ -# cli-e2e environment — copy to `.env.local` (gitignored) and fill in. -# Only the live/record modes need real values; replay mode (the default) needs none. +# cli-e2e replay/record environment — copy to `.env.local` (gitignored) and fill in. +# Replay mode (the default) needs no environment variables. -# Mode: replay (default, no creds) | record (capture fixtures) | live (ADR-0013). -CLI_E2E_MODE=live - -# Backend the live/record suite targets. Only `staging` is wired today. -CLI_E2E_TARGET_ENV=staging +# Set RECORD=true (or CLI_E2E_MODE=record) to capture fixtures from staging. +CLI_E2E_MODE=record # CLI target under test: ts-legacy (the shipped shell, default) | ts-next. # (The `go` target was retired when the Go CLI was trimmed to the proxied subset.) CLI_HARNESS_TARGET=ts-legacy -# Staging Management API token. Either name works (the suite also reads -# SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN). Required in record/live mode. +# Staging Management API token. Required in record mode. SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # ts-legacy shells out to the bundled Go binary for the proxied commands @@ -21,14 +17,9 @@ SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # cd apps/cli-go && go build -o /tmp/supabase-test-binary . SUPABASE_GO_BINARY=/tmp/supabase-test-binary -# --- Optional overrides (sensible defaults in src/tests/env.ts) --- -# Management API base + per-project host (default to staging: api.supabase.green / supabase.red). +# --- Optional record overrides (sensible defaults in src/tests/env.ts) --- +# Management API base (also accepted as SUPABASE_STAGING_URL). # CLI_E2E_API_URL=https://api.supabase.green -# CLI_E2E_PROJECT_HOST=supabase.red -# DB password for the ephemeral project (default: random per run). +# DB password for the recording project (default: random per run). # CLI_E2E_DB_PASSWORD= -# Skip org resolution / region / pick a specific org. -# CLI_E2E_ORG_ID= # CLI_E2E_REGION=us-east-1 -# Leave the ephemeral live project alive after the run (debugging). -# CLI_E2E_KEEP_PROJECT=1 diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index b309111a1f..d66cc8f665 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -141,18 +141,6 @@ In **record mode**: global setup resolves the org, deletes any orphaned test pro The pre-recording cleanup deletes projects named `cli-e2e-test`, `my-project`, and `to-delete` so re-recording never hits a 409 name-conflict. Do not add tests that rely on pre-existing named projects existing on staging. -## Live mode (ADR-0013) - -`live` is a third mode (`CLI_E2E_MODE=live`) that, unlike replay/record, **does not use the replay server**. The harness is wired straight at the real Management API (`CLI_E2E_API_URL`) and the real Docker socket; tests assert on **real outcomes**. - -- Live tests are `src/tests/live/**/*.live.e2e.test.ts`, run only via `vitest.live.config.ts` (the default config excludes them). They `skipIf(!isLive)`, so they are inert on the replay suite. -- Global setup (`tests/live-setup.ts`) provisions **one ephemeral project per run** (`cli-e2e-live-{target}-{runId}-{short}`), waits for `ACTIVE_HEALTHY`, resolves the anon JWT, the IPv4 **session-pooler `dbUrl`** (for `--db-url` DB commands), the functions URL, and a seeded storage bucket, exposing them via `inject()`. It deletes the project on teardown (even on failure). Setup is intentionally **dumb** — no provisioning retry; the CI job re-runs the step on flake. -- Use `testLive` from `src/tests/live/live-context.ts`: `run(cmd)` (direct-wired CLI), `invoke(slug)` (direct HTTP call sending the **anon JWT** in both `Authorization: Bearer` and `apikey`), plus `workspace` (a fresh `supabase init` config so golden paths exercise a generated config), `projectRef`, `anonKey`, `functionsUrl`, `dbUrl`, `storageBucket`. The functions deploy tests call `seedFunctions(workspace.path)` to layer the `deploy-e2e-*` fixtures + their `[functions.*]` config onto the init'd config. -- **Assertion style:** outcome-based — assert `exitCode`/`stdout` substrings and the function's HTTP status + JSON body. This is ID-agnostic, so **no normalization/snapshots by default**. If the CLI's own diagnostic output is ever the assertion target, add a scoped normalizer for that one test — do not make normalization the default. -- **Authoring/CI target is `ts-legacy`** — the only shipped CLI shell. It still shells out to the Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `SUPABASE_GO_BINARY` must point at a built Go binary for those to resolve. -- Retargeting to another env (e.g. `supabox`) is an env swap only: `CLI_E2E_TARGET_ENV` + `CLI_E2E_API_URL` + `CLI_E2E_PROJECT_HOST` + token. Tests assert on function output, not hostnames. -- **CI triggers** (`.github/workflows/live-e2e.yml`): `workflow_dispatch` (manual; the Actions branch picker selects the ref — no free-form `ref` input, so the staging token never reaches arbitrary code) and an hourly `schedule`. There is **no `pull_request` trigger** — run it manually on a PR branch for pre-merge coverage. The scheduled run exercises the `@beta` channel: `develop` is the default branch and the beta release source, so it builds from `develop` source and runs the `ts-legacy` job. A `gate` job skips the run unless the published `supabase@beta` version changed since the last green run (an `actions/cache` marker keyed on the version, written by `finalize` only after the job passes), so a staging project is spent only when there is a new beta to test. Because the marker is written only on a green run, a chronically-failing `@beta` keeps re-running every hour until it goes green or a newer beta supersedes it (intended — the failure stays visible). - ## Running the suite ```sh @@ -162,18 +150,11 @@ pnpm nx run @supabase/cli-e2e:test:legacy # ts-legacy target # Record (requires staging access) SUPABASE_ACCESS_TOKEN=sbp_... SUPABASE_STAGING_URL=https://api.supabase.green \ pnpm nx run @supabase/cli-e2e:record - -# Live (requires staging access; creates + deletes a real project; needs Docker). -# Build the Go binary first so newly-added proxy commands resolve (the system -# `supabase` may be stale) — mirrors what CI does. -cd apps/cli-go && go build -o /tmp/supabase-test-binary . && cd - -SUPABASE_GO_BINARY=/tmp/supabase-test-binary \ - SUPABASE_ACCESS_TOKEN=sbp_... \ - pnpm --filter @supabase/cli-e2e test:e2e:live ``` -See `apps/cli-e2e/.env.example` for the full set of live/record env vars (copy to -a gitignored `.env.local`). +See `apps/cli-e2e/.env.example` for replay/record env vars (copy to a gitignored +`.env.local`). Live environment setup is documented in `apps/cli/AGENTS.md` and +`apps/cli/live.env.example`. After recording, replay must pass with no changes between the two commands. diff --git a/apps/cli-e2e/fixtures/live/functions-config.toml b/apps/cli-e2e/fixtures/live/functions-config.toml deleted file mode 100644 index d210d7800e..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-config.toml +++ /dev/null @@ -1,19 +0,0 @@ -# Per-function config appended onto the `supabase init`-generated config.toml by -# seedFunctions() for the functions deploy tests (the import-map, custom -# entrypoint, static-file, and no-jwt fixtures need these). Everything else runs -# against the bare generated config. - -[functions."deploy-e2e-root-map"] -import_map = "./import_map.json" - -[functions."deploy-e2e-custom-entry"] -entrypoint = "./functions/deploy-e2e-custom-entry/handler.ts" - -[functions."deploy-e2e-static-in-fn"] -static_files = ["./functions/deploy-e2e-static-in-fn/static/*.txt"] - -[functions."deploy-e2e-static-asset"] -static_files = ["./assets/*.svg", "./functions/deploy-e2e-static-asset/assets/*.svg"] - -[functions."deploy-e2e-no-jwt"] -verify_jwt = false diff --git a/apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg b/apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg deleted file mode 100644 index 914f94e2e0..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/assets/badge.svg +++ /dev/null @@ -1,3 +0,0 @@ - - outside-static - diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts deleted file mode 100644 index d901eb79d4..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/_shared/greet.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = () => "hello"; diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts deleted file mode 100644 index cc000c3fc7..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-basic/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-basic", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts deleted file mode 100644 index ff43ad2065..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-custom-entry/handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -Deno.serve(() => - Response.json({ case: "deploy-e2e-custom-entry", ok: true, entry: "handler.ts" }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc deleted file mode 100644 index 6f14fbcc6e..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/deno.jsonc +++ /dev/null @@ -1,6 +0,0 @@ -{ - // scoped alias with comments - "imports": { - "@shared/": "../_shared/" - } -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts deleted file mode 100644 index 8b1ba2da96..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deno-jsonc/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@shared/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-deno-jsonc", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json deleted file mode 100644 index 4e99a415b5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/import_map.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@shared/": "../_shared/" - } -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts deleted file mode 100644 index 21231dc870..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-deprecated-map/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@shared/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-deprecated-map", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts deleted file mode 100644 index 41a2055f44..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -Deno.serve(async () => { - const { value } = await import("./lazy.ts"); - return Response.json({ case: "deploy-e2e-dynamic-import", ok: true, value }); -}); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts deleted file mode 100644 index 636afa7830..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-dynamic-import/lazy.ts +++ /dev/null @@ -1 +0,0 @@ -export const value = "lazy-ok"; diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts deleted file mode 100644 index b136d09c48..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jsr/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - -Deno.serve((req) => - Response.json({ case: "deploy-e2e-jsr", ok: true, method: req.method }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts deleted file mode 100644 index 81648d03de..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-jwt-required/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-jwt-required", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts deleted file mode 100644 index 16e3e308e4..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const suffix = "-imports"; diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts deleted file mode 100644 index fb7ea13f9d..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-local-imports/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { greet } from "../_shared/greet.ts"; -import { suffix } from "./helpers.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-local-imports", ok: true, message: greet() + suffix }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json deleted file mode 100644 index f6ca8454c5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts deleted file mode 100644 index e344e16514..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-api/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-mode-api", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json deleted file mode 100644 index f6ca8454c5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts deleted file mode 100644 index dbdfe144ff..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-default/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-mode-default", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json deleted file mode 100644 index f6ca8454c5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts deleted file mode 100644 index fcd8ea060a..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-mode-docker/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-mode-docker", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts deleted file mode 100644 index 1697305182..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-no-jwt/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-no-jwt", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts deleted file mode 100644 index 76b0dbb54a..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-npm/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createClient } from "npm:@supabase/supabase-js@2"; - -Deno.serve(() => { - const client = createClient("https://example.supabase.co", "anon-key"); - return Response.json({ - case: "deploy-e2e-npm", - ok: true, - hasClient: typeof client.from === "function", - }); -}); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts deleted file mode 100644 index c2671f20ac..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-package-json", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json deleted file mode 100644 index b667d153ab..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-package-json/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "module", - "dependencies": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts deleted file mode 100644 index b911f4475e..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-remote-only/index.ts +++ /dev/null @@ -1 +0,0 @@ -Deno.serve(() => Response.json({ case: "deploy-e2e-remote-only", ok: true })); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts deleted file mode 100644 index fd1cd5a53f..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-root-map/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@root/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-root-map", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json deleted file mode 100644 index 4e99a415b5..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/deno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@shared/": "../_shared/" - } -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts deleted file mode 100644 index 783b8506d6..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-scoped-map/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { greet } from "@shared/greet.ts"; - -Deno.serve(() => - Response.json({ case: "deploy-e2e-scoped-map", ok: true, message: greet() }) -); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg deleted file mode 100644 index 914f94e2e0..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/assets/badge.svg +++ /dev/null @@ -1,3 +0,0 @@ - - outside-static - diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json deleted file mode 100644 index 80c4e4a920..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/deno.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "imports": {} -} diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts deleted file mode 100644 index 9a598ec2c9..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-asset/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// static_files bundles supabase/assets/*.svg (outside functions/) plus the function-local -// assets/ copy used at runtime (same pattern as deploy-e2e-static-in-fn). -Deno.serve(async () => { - const svg = await Deno.readTextFile(new URL("./assets/badge.svg", import.meta.url)); - return Response.json({ - case: "deploy-e2e-static-asset", - ok: true, - static: svg.includes("outside-static") || svg.includes(" { - const text = await Deno.readTextFile(new URL("./static/note.txt", import.meta.url)); - return Response.json({ - case: "deploy-e2e-static-in-fn", - ok: true, - static: text.trim(), - }); -}); diff --git a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt b/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt deleted file mode 100644 index 99337dc661..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/functions/deploy-e2e-static-in-fn/static/note.txt +++ /dev/null @@ -1 +0,0 @@ -in-fn-static diff --git a/apps/cli-e2e/fixtures/live/functions-project/import_map.json b/apps/cli-e2e/fixtures/live/functions-project/import_map.json deleted file mode 100644 index c84d752202..0000000000 --- a/apps/cli-e2e/fixtures/live/functions-project/import_map.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "@root/": "./functions/_shared/" - } -} diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index 84261f76a4..3d34d2ca2b 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -8,7 +8,6 @@ "test:e2e": "bun --bun vitest run", "test:legacy": "CLI_HARNESS_TARGET=ts-legacy bun --bun vitest run", "test:next": "CLI_HARNESS_TARGET=ts-next bun --bun vitest run", - "test:e2e:live": "CLI_E2E_MODE=live CLI_E2E_TARGET_ENV=staging bun --bun vitest run --config vitest.live.config.ts", "record": "RECORD=true CLI_HARNESS_TARGET=ts-legacy bun --bun vitest run", "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" @@ -30,9 +29,8 @@ "knip": { "entry": [ "src/**/*.e2e.test.ts", - "src/**/*.live.e2e.test.ts", "tests/**/*.ts", - "vitest.live.config.ts" + "vitest.config.ts" ], "ignore": [ "fixtures/**" diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 57c1af2cc7..50f70ddbc4 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,18 +1,15 @@ import type { CLITarget } from "@supabase/cli-test-helpers"; -type CliE2eMode = "replay" | "record" | "live"; -type CliE2eTargetEnv = "staging" | "supabox"; +type CliE2eMode = "replay" | "record"; // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to -// staging and captures fixtures; `live` (ADR-0013) bypasses the replay server and -// wires the CLI straight at the real Management API + Docker socket. +// staging and captures fixtures. // Back-compat: RECORD=true still maps to `record`. const MODE: CliE2eMode = (process.env["CLI_E2E_MODE"] as CliE2eMode | undefined) ?? (process.env["RECORD"] === "true" ? "record" : "replay"); export const isRecording = MODE === "record"; -export const isLive = MODE === "live"; // The replay server + tests/setup.ts key recording off the RECORD env var // directly. Keep RECORD in sync with MODE in BOTH directions so an explicit @@ -31,42 +28,14 @@ if (isRecording && !process.env["SUPABASE_STAGING_URL"] && process.env["CLI_E2E_ process.env["SUPABASE_STAGING_URL"] = process.env["CLI_E2E_API_URL"]; } -// Which backend the live/record suite targets. Only `staging` is wired today; -// `supabox` is a later env swap (CLI_E2E_API_URL + CLI_E2E_PROJECT_HOST + token). -const TARGET_ENV: CliE2eTargetEnv = - (process.env["CLI_E2E_TARGET_ENV"] as CliE2eTargetEnv | undefined) ?? "staging"; - -// Base Management API URL for record/live modes (the real API). In live mode the -// harness apiUrl is wired here directly — there is no replay server in front. -// Replay mode never reads this. -export const TARGET_API_URL = - process.env["CLI_E2E_API_URL"] ?? - process.env["SUPABASE_STAGING_URL"] ?? - "https://api.supabase.green"; - -// Host used to build the deployed-function invoke URL: -// https://{ref}.{PROJECT_HOST}/functions/v1 -// Environment-specific (staging is not supabase.co), so it is configurable. -export const PROJECT_HOST = - process.env["CLI_E2E_PROJECT_HOST"] ?? (TARGET_ENV === "staging" ? "supabase.red" : ""); - -// In replay mode the token never reaches a real API, but the Go CLI validates -// the format before making any request (must match sbp_[a-f0-9]{40}). -// In record/live mode it must be a valid token for the target env. Falls back to -// the live staging secret name so a local `.env.local` works without remapping. +// In replay mode the token never reaches a real API, but the CLI validates the +// format before making any request (must match sbp_[a-f0-9]{40}). In record mode +// it must be a valid token for the staging API. export const ACCESS_TOKEN = - process.env["SUPABASE_ACCESS_TOKEN"] ?? - process.env["SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN"] ?? - "sbp_0000000000000000000000000000000000000000"; - -// Whether a real token was supplied (vs the replay placeholder above). Live mode -// must fail fast on a missing token instead of letting every API call 401. -export const isAccessTokenProvided = Boolean( - process.env["SUPABASE_ACCESS_TOKEN"] ?? process.env["SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN"], -); + process.env["SUPABASE_ACCESS_TOKEN"] ?? "sbp_0000000000000000000000000000000000000000"; // Which target to run. Defaults to "ts-legacy" — the only shipped CLI shell and -// therefore the authoritative target for both recording and live tests. Validated +// therefore the authoritative target for replay and recording. Validated // eagerly so a stale value (e.g. the retired "go" target) fails with a clear error // instead of an undefined-command crash inside the harness. const VALID_TARGETS: ReadonlyArray = ["ts-legacy", "ts-next"]; @@ -80,16 +49,9 @@ if (matchedTarget === undefined) { } export const TARGET = matchedTarget; -// Optional org for the fresh live project. When unset, live-setup resolves it via -// `orgs list` (which also exercises that command against the real API). -export const ORG_ID_OVERRIDE = process.env["CLI_E2E_ORG_ID"]; - -// Region for the fresh live project. +// Region for the fresh recording project. export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; -// Skip live-project teardown for debugging. -export const KEEP_PROJECT = process.env["CLI_E2E_KEEP_PROJECT"] === "1"; - // In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__ // in the fixture key. In record mode supply a real project ref via env. export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? "aaaaaaaaaaaaaaaaaaaa"; diff --git a/apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts deleted file mode 100644 index 5c88ce20ce..0000000000 --- a/apps/cli-e2e/src/tests/live/branches.live.e2e.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Preview branches (workflow 3). `branches create` provisions a real branch and -// requires a paid plan; the cli-e2e test org may be on the free plan, in which -// case the CLI must surface the plan requirement rather than crash. Handle both: -// on a paid org, create → list → delete; on a free org, assert the plan-gate. -describe("branches (live)", () => { - testLive("create + list + delete (or surface the plan gate)", async ({ run, projectRef }) => { - // Unique per attempt so a retry (vitest retry:2) after a post-create flake - // can't collide on the name; a finally guarantees cleanup either way. - const name = `e2e-branch-${Date.now()}`; - const created = await run(["branches", "create", name, "--project-ref", projectRef]); - - if (created.exitCode !== 0) { - // Free-plan org: the command must clearly report that branching needs a - // paid plan (not fail opaquely). - expect(created.stderr, created.stderr).toMatch(/paid plan|upgrade|not.*support/i); - return; - } - - let branchDeleted = false; - try { - expect(created.stdout).toContain("Created preview branch"); - - const listed = await run([ - "branches", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(listed.exitCode, listed.stderr).toBe(0); - const names = (JSON.parse(listed.stdout) as Array<{ name?: string }>).map((b) => b.name); - expect(names).toContain(name); - - const deleted = await run(["branches", "delete", name, "--project-ref", projectRef, "--yes"]); - expect(deleted.exitCode, deleted.stderr).toBe(0); - branchDeleted = true; - } finally { - // Retry/leak safety: clean up only if the in-try delete didn't already - // succeed (e.g. an earlier assertion threw). Tolerates a not-found branch. - if (!branchDeleted) { - await run(["branches", "delete", name, "--project-ref", projectRef, "--yes"]); - } - } - }); -}); diff --git a/apps/cli-e2e/src/tests/live/database.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/database.live.e2e.test.ts deleted file mode 100644 index 6a99f7131f..0000000000 --- a/apps/cli-e2e/src/tests/live/database.live.e2e.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// DB-connectivity commands against the fresh project's Postgres via the IPv4 -// session-mode Supavisor pooler (`dbUrl` from live-setup). The direct host -// (db..supabase.red) is IPv6-only and unreachable from IPv4-only CI -// runners; the pooler is IPv4, and session mode is required for pg_dump. -// A non-zero exit here means the connection itself failed. -describe("database (live, session pooler --db-url)", () => { - testLive("inspect db db-stats connects and reports stats", async ({ run, dbUrl }) => { - const res = await run(["inspect", "db", "db-stats", "--db-url", dbUrl]); - expect(res.exitCode, res.stderr).toBe(0); - expect(res.stdout).toContain("Database Size"); - }); - - testLive("migration list connects to the remote migration history", async ({ run, dbUrl }) => { - const res = await run(["migration", "list", "--db-url", dbUrl]); - // Fresh project has no migrations, but exit 0 proves it connected and - // queried the remote history table. - expect(res.exitCode, res.stderr).toBe(0); - }); - - testLive("db dump exports the remote schema", async ({ run, dbUrl, workspace }) => { - const file = join(workspace.path, "dump.sql"); - const res = await run(["db", "dump", "--db-url", dbUrl, "-f", file]); - expect(res.exitCode, res.stderr).toBe(0); - expect(existsSync(file)).toBe(true); - expect(readFileSync(file, "utf8")).toMatch(/CREATE|PostgreSQL database dump|SCHEMA/i); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts deleted file mode 100644 index d3fba9d7e7..0000000000 --- a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { TARGET } from "../env.ts"; -import { testLive } from "./live-context.ts"; - -// Real-backend live coverage for the native `db start` / `db reset` ports. -// -// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` -// port (the `next` shell has no `db` group), so skip the `ts-next` target. -// -// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the -// destructive remote reset below is safe against the throwaway per-run project. - -// --- Local leg: db start + db reset --local against the real Docker socket ----- -// Exercises `db start`'s native container-bootstrap sequence (network/volume/container -// bring-up, health wait, the fresh-volume SetupLocalDatabase-equivalent pipeline, and -// `_current_branch`) and `db reset --local`'s container-recreate flow end-to-end — the -// real-Docker boundary the in-process integration suites mock. Both are fully native TS -// now: `db reset --local`'s hidden Go `db __db-bootstrap` seam (`--mode recreate`/ -// `--mode await-storage`) was removed in CLI-1955 (see -// `commands/db/reset/reset.handler.ts` / `shared/db-bootstrap/recreate-local-database.ts`), -// the same way `db start`'s own seam usage was removed in CLI-1954 (see -// `commands/db/start/start.handler.ts`). The start → already-running → reset cycle runs -// in one test so it shares a single booted stack, and `finally` stops it (legacy proxies -// `stop` to Go) so the run never leaves containers behind. -describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { - testLive( - "db start boots, is idempotent, and db reset --local recreates", - { timeout: 600_000 }, - async ({ run }) => { - try { - const start = await run(["db", "start"]); - expect(start.exitCode, start.stderr).toBe(0); - // Bootstrap progress goes to stderr on every target (Go, and native TS since CLI-1954). - expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); - - // Second start is a no-op: the db is already running, exit 0. - const again = await run(["db", "start"]); - expect(again.exitCode, again.stderr).toBe(0); - expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); - - // Local reset recreates the container and prints the git-branch line. - const reset = await run(["db", "reset", "--local"]); - expect(reset.exitCode, reset.stderr).toBe(0); - expect(reset.stderr).toContain("on branch "); - } finally { - await run(["stop", "--no-backup"]).catch(() => undefined); - } - }, - ); -}); - -// --- Remote leg: db reset against the staging project over the session pooler --- -// Exercises the native remote reset path (drop user schemas → apply local -// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the -// confirmation prompt (the non-interactive default is decline). Mutates the -// throwaway project's schema — deleted on teardown. The IPv4 session pooler -// `dbUrl` is used because the direct host is IPv6-only and unreachable from -// IPv4-only CI runners. -describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { - testLive( - "resets the remote schema and re-applies a local migration", - { timeout: 600_000 }, - async ({ run, dbUrl, workspace }) => { - const migrations = join(workspace.path, "supabase", "migrations"); - mkdirSync(migrations, { recursive: true }); - writeFileSync( - join(migrations, "20240101000000_e2e_reset.sql"), - "create table if not exists e2e_reset (id int);\n", - ); - - const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); - expect(reset.exitCode, reset.stderr).toBe(0); - expect(reset.stderr).toContain("Resetting remote database"); - // A real connection failure must never be mistaken for a benign outcome. - expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( - /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, - ); - - // The migration history shows the re-applied version → proves the drop + - // migrate ran against the remote database. - const listed = await run(["migration", "list", "--db-url", dbUrl]); - expect(listed.exitCode, listed.stderr).toBe(0); - expect(listed.stdout).toContain("20240101000000"); - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts deleted file mode 100644 index 4780b56537..0000000000 --- a/apps/cli-e2e/src/tests/live/db-sync.live.e2e.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Local↔remote schema sync (workflows 1-2) over the IPv4 session pooler. Done as -// one round-trip in a single workspace: pushing first makes the local migration -// history match the remote, so the subsequent pull's consistency check passes -// (a separate fresh-workspace pull would see a history mismatch on the shared -// per-run project). db push/pull confirm via a prompt that only auto-accepts -// with --yes. Mutates the throwaway project's schema — deleted on teardown. -describe("db push + pull (live, session pooler)", () => { - testLive( - "pushes a local migration and pulls the remote schema back", - async ({ run, dbUrl, workspace }) => { - const migrations = join(workspace.path, "supabase", "migrations"); - mkdirSync(migrations, { recursive: true }); - writeFileSync( - join(migrations, "20240101000000_e2e_push.sql"), - "create table if not exists e2e_push (id int);\n", - ); - - const pushed = await run(["db", "push", "--db-url", dbUrl, "--yes"]); - expect(pushed.exitCode, pushed.stderr).toBe(0); - - const listed = await run(["migration", "list", "--db-url", dbUrl]); - expect(listed.exitCode, listed.stderr).toBe(0); - expect(listed.stdout).toContain("20240101000000"); - - // Local history now matches remote, so pull connects and runs the diff. - // It either finds a remote-only change (exit 0, writes a migration) or - // reports no changes — both prove connectivity; only a real connection - // failure would surface a different error. - const pulled = await run(["db", "pull", "--db-url", dbUrl, "--yes"]); - const pullOutput = `${pulled.stdout}${pulled.stderr}`; - // The point of this test is connectivity over the pooler: a real connection - // failure must never be mistaken for a benign "no changes" outcome. - expect(pullOutput, "db pull hit a connection error").not.toMatch( - /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, - ); - expect( - pulled.exitCode === 0 || /No schema changes found/i.test(pullOutput), - pulled.stderr, - ).toBe(true); - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts deleted file mode 100644 index e9101cc1be..0000000000 --- a/apps/cli-e2e/src/tests/live/functions-deploy.live.e2e.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readdirSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { expectFunctionOk } from "./invoke.ts"; -import { seedFunctions, testLive } from "./live-context.ts"; - -// Pilot (ADR-0013): deploy with the real CLI across the three bundler paths, -// then invoke the deployed function over HTTP and assert the body it returns. -// Each mode deploys a DISTINCT slug so the invoke proves THAT mode's deploy -// produced a running function — the shared project means a single slug could -// otherwise be served by an earlier mode's deploy. Negative/arg-validation -// cases live in apps/cli integration tests. -const MODES = [ - { name: "default", slug: "deploy-e2e-mode-default", flags: [] as string[] }, - { name: "use-api", slug: "deploy-e2e-mode-api", flags: ["--use-api"] }, - { name: "use-docker", slug: "deploy-e2e-mode-docker", flags: ["--use-docker"] }, -] as const; - -describe.each(MODES)("functions deploy ($name)", ({ slug, flags }) => { - testLive("deploys and the function responds", async ({ run, invoke, workspace, projectRef }) => { - seedFunctions(workspace.path); - const deployed = await run([ - "functions", - "deploy", - slug, - "--project-ref", - projectRef, - ...flags, - ]); - expect(deployed.exitCode, deployed.stderr).toBe(0); - expect(deployed.stdout).toContain("Deployed Functions"); - - const res = await invoke(slug); - expectFunctionOk(res, slug); - }); -}); - -// No slug → the CLI walks every function declared under supabase/functions and -// deploys them all. Assert each declared function appears in the deploy output, -// then smoke-invoke a representative one. -testLive( - "deploys every declared function when no slug is given", - async ({ run, invoke, workspace, projectRef }) => { - seedFunctions(workspace.path); - const declared = readdirSync(join(workspace.path, "supabase", "functions"), { - withFileTypes: true, - }) - .filter((e) => e.isDirectory() && !e.name.startsWith("_")) - .map((e) => e.name); - expect(declared.length).toBeGreaterThan(1); - - const deployed = await run(["functions", "deploy", "--project-ref", projectRef]); - expect(deployed.exitCode, deployed.stderr).toBe(0); - expect(deployed.stdout).toContain("Deployed Functions"); - - // Each declared function must be listed in the deploy output AND respond - // with its own {case: slug, ok: true}. A handler returns that marker only if - // it actually executed — and for the npm/jsr/local-imports/scoped-map - // fixtures only if their imports resolved at runtime — so this proves the - // feature ran end-to-end, not merely that the function deployed and booted. - for (const slug of declared) { - expect(deployed.stdout, `expected "${slug}" in deploy output`).toContain(slug); - expectFunctionOk(await invoke(slug), slug); - } - }, -); diff --git a/apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts deleted file mode 100644 index b800b8bda2..0000000000 --- a/apps/cli-e2e/src/tests/live/functions-lifecycle.live.e2e.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Write a throwaway Edge Function into the test workspace so the lifecycle tests -// own a dedicated slug (the shared per-run project is cleaned up on teardown). -function writeFunction(workspacePath: string, slug: string, jsonBody: string): void { - const dir = join(workspacePath, "supabase", "functions", slug); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "index.ts"), `Deno.serve(() => Response.json(${jsonBody}));\n`); - writeFileSync(join(dir, "deno.json"), `{\n "imports": {}\n}\n`); -} - -// Active (non-REMOVED) function slugs. The Management API can keep deleted -// functions in the list with status REMOVED (the Go prune path skips them), so a -// successful delete may leave a REMOVED row — filter those out. -function activeSlugs(stdout: string): string[] { - return (JSON.parse(stdout) as Array<{ slug?: string; name?: string; status?: string }>) - .filter((f) => (f.status ?? "").toUpperCase() !== "REMOVED") - .map((f) => f.slug ?? f.name ?? ""); -} - -describe("functions update + delete (live)", () => { - // There is no dedicated `functions update` command — re-deploying a slug - // upserts it. Verify the second deploy replaces the running code. - testLive( - "re-deploying a function updates the running code", - async ({ run, invoke, workspace, projectRef }) => { - const slug = "deploy-e2e-update"; - - writeFunction(workspace.path, slug, `{ case: "${slug}", version: 1 }`); - expect((await run(["functions", "deploy", slug, "--project-ref", projectRef])).exitCode).toBe( - 0, - ); - expect((await invoke(slug)).body).toMatchObject({ case: slug, version: 1 }); - - writeFunction(workspace.path, slug, `{ case: "${slug}", version: 2 }`); - expect((await run(["functions", "deploy", slug, "--project-ref", projectRef])).exitCode).toBe( - 0, - ); - expect((await invoke(slug)).body).toMatchObject({ case: slug, version: 2 }); - }, - ); - - testLive("delete removes a deployed function", async ({ run, workspace, projectRef }) => { - const slug = "deploy-e2e-delete"; - - writeFunction(workspace.path, slug, `{ case: "${slug}", ok: true }`); - expect((await run(["functions", "deploy", slug, "--project-ref", projectRef])).exitCode).toBe( - 0, - ); - - const before = await run([ - "functions", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(before.exitCode, before.stderr).toBe(0); - expect(activeSlugs(before.stdout)).toContain(slug); - - const del = await run(["functions", "delete", slug, "--project-ref", projectRef]); - expect(del.exitCode, del.stderr).toBe(0); - expect(del.stdout).toContain("Deleted Function"); - - const after = await run(["functions", "list", "--output", "json", "--project-ref", projectRef]); - expect(after.exitCode, after.stderr).toBe(0); - expect(activeSlugs(after.stdout)).not.toContain(slug); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts deleted file mode 100644 index e75455989b..0000000000 --- a/apps/cli-e2e/src/tests/live/gen-types.live.e2e.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// gen types introspects the remote schema over the IPv4 session pooler and emits -// TypeScript types. It pulls the postgres-meta Docker image, so it needs Docker -// (present in the CI live job alongside the --use-docker bundler cell). -describe("gen types (live, session pooler)", () => { - testLive("generates TypeScript types from the remote schema", async ({ run, dbUrl }) => { - const res = await run(["gen", "types", "--db-url", dbUrl, "--lang", "typescript"]); - expect(res.exitCode, res.stderr).toBe(0); - expect(res.stdout).toMatch(/export type (Database|Json)/); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/invoke.ts b/apps/cli-e2e/src/tests/live/invoke.ts deleted file mode 100644 index a5efcb3e58..0000000000 --- a/apps/cli-e2e/src/tests/live/invoke.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect } from "vitest"; - -export interface InvokeResult { - status: number; - body: unknown; - text: string; -} - -/** Direct HTTP-invoke a deployed Edge Function and return status + parsed body. - * The replay server is not involved (ADR-0013) — this is a real call to the - * deployed function. Staging expects the publishable/anon key in BOTH the - * Authorization Bearer header and the apikey header. */ -export async function invokeFunction(opts: { - functionsUrl: string; - slug: string; - anonKey?: string; - payload?: unknown; -}): Promise { - const headers: Record = { "Content-Type": "application/json" }; - if (opts.anonKey) { - headers["Authorization"] = `Bearer ${opts.anonKey}`; - headers["apikey"] = opts.anonKey; - } - const res = await fetch(`${opts.functionsUrl}/${opts.slug}`, { - method: "POST", - headers, - body: JSON.stringify(opts.payload ?? {}), - }); - const text = await res.text(); - let body: unknown; - try { - body = JSON.parse(text); - } catch { - body = text; - } - return { status: res.status, body, text }; -} - -/** Assert the playbook's default per-slug expectation: 200 + `{case: slug, ok: true}`. */ -export function expectFunctionOk( - result: InvokeResult, - slug: string, - extra?: Record, -): void { - expect(result.status, result.text).toBe(200); - expect(result.body).toMatchObject({ case: slug, ok: true, ...extra }); -} diff --git a/apps/cli-e2e/src/tests/live/link.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/link.live.e2e.test.ts deleted file mode 100644 index f0c75b5e7b..0000000000 --- a/apps/cli-e2e/src/tests/live/link.live.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// `link` is the backbone of workflows 1-3. --skip-pooler keeps it -// Management-API-only (no IPv6-only DB connection): it validates the ref and -// writes the linked-project cache into the workspace's supabase/.temp. -describe("link (live)", () => { - testLive("links the project so ref-less commands resolve it", async ({ run, projectRef }) => { - const linked = await run(["link", "--project-ref", projectRef, "--skip-pooler"]); - expect(linked.exitCode, linked.stderr).toBe(0); - expect(linked.stdout).toContain("Finished supabase link"); - - // No --project-ref and no SUPABASE_PROJECT_ID env: a remote command must now - // resolve the ref from the link written above. - const listed = await run(["secrets", "list", "--output", "json"], { - env: { SUPABASE_PROJECT_ID: "" }, - }); - expect(listed.exitCode, listed.stderr).toBe(0); - expect(Array.isArray(JSON.parse(listed.stdout))).toBe(true); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/live-context.ts b/apps/cli-e2e/src/tests/live/live-context.ts deleted file mode 100644 index 2cabe016e3..0000000000 --- a/apps/cli-e2e/src/tests/live/live-context.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { appendFileSync, cpSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { inject, test } from "vitest"; -import { - createHarness, - exec, - makeTempDir, - type CLIResult, - type TempDir, -} from "@supabase/cli-test-helpers"; -import { ACCESS_TOKEN, isLive, PROJECT_HOST, TARGET, TARGET_API_URL } from "../env.ts"; -import { invokeFunction, type InvokeResult } from "./invoke.ts"; - -type ExecOptions = NonNullable[2]>; - -// deploy-e2e-* function files (functions/, import_map.json, assets/) + the -// [functions.*] config snippet, layered onto an init-generated config by -// seedFunctions() for the functions deploy tests. -const FUNCTIONS_PROJECT_DIR = new URL("../../../fixtures/live/functions-project", import.meta.url) - .pathname; -const FUNCTIONS_CONFIG_SNIPPET = new URL( - "../../../fixtures/live/functions-config.toml", - import.meta.url, -).pathname; - -function liveHarness(cwd: string) { - return createHarness(TARGET, { - apiUrl: TARGET_API_URL, - accessToken: ACCESS_TOKEN, - cwd, - projectId: inject("projectRef"), - // Real host so host-derived commands (storage --linked → .) reach - // the live endpoint instead of localhost. - projectHost: PROJECT_HOST, - }); -} - -/** Layer the deploy-e2e-* function files + their [functions.*] config onto an - * init-generated workspace. Used by the functions deploy tests; every other - * test runs against the bare `supabase init` config. */ -export function seedFunctions(workspacePath: string): void { - const supabaseDir = join(workspacePath, "supabase"); - cpSync(FUNCTIONS_PROJECT_DIR, supabaseDir, { recursive: true }); - appendFileSync( - join(supabaseDir, "config.toml"), - `\n${readFileSync(FUNCTIONS_CONFIG_SNIPPET, "utf8")}`, - ); -} - -interface LiveFixtures { - projectRef: string; - anonKey: string; - functionsUrl: string; - dbUrl: string; - dbPassword: string; - storageBucket: string; - workspace: TempDir; - run: (cmd: string[], execOpts?: ExecOptions) => Promise; - invoke: (slug: string, opts?: { anonKey?: string; payload?: unknown }) => Promise; -} - -const base = test.extend({ - // eslint-disable-next-line no-empty-pattern - projectRef: async ({}, use) => { - await use(inject("projectRef")); - }, - - // eslint-disable-next-line no-empty-pattern - anonKey: async ({}, use) => { - await use(inject("anonKey")); - }, - - // eslint-disable-next-line no-empty-pattern - functionsUrl: async ({}, use) => { - await use(inject("functionsUrl")); - }, - - // eslint-disable-next-line no-empty-pattern - dbUrl: async ({}, use) => { - await use(inject("dbUrl")); - }, - - // eslint-disable-next-line no-empty-pattern - dbPassword: async ({}, use) => { - await use(inject("dbPassword")); - }, - - // eslint-disable-next-line no-empty-pattern - storageBucket: async ({}, use) => { - await use(inject("storageBucket")); - }, - - workspace: async ({ task }, use) => { - const dir = makeTempDir(`cli-e2e-live-${task.name.slice(0, 30)}-`); - // Generate config.toml via `supabase init` so the golden paths run against a - // freshly-generated config (functions tests add functions via seedFunctions). - const init = await exec(liveHarness(dir.path), ["init"]); - if (init.exitCode !== 0) throw new Error(`supabase init failed: ${init.stderr}`); - await use(dir); - dir[Symbol.dispose](); - }, - - run: async ({ workspace }, use) => { - const harness = liveHarness(workspace.path); - await use((cmd, execOpts) => exec(harness, cmd, execOpts)); - }, - - invoke: async ({ functionsUrl, anonKey }, use) => { - await use((slug, opts) => - invokeFunction({ - functionsUrl, - slug, - anonKey: opts && "anonKey" in opts ? opts.anonKey : anonKey, - payload: opts?.payload, - }), - ); - }, -}); - -/** Live test API — skipped unless CLI_E2E_MODE=live, so files are inert on - * replay/PR runs (and globalSetup provisions nothing). */ -export const testLive = base.skipIf(!isLive); diff --git a/apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts deleted file mode 100644 index 1b17aad672..0000000000 --- a/apps/cli-e2e/src/tests/live/projects.live.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// projects create/delete are exercised implicitly by live-setup (it provisions -// and tears down the per-run project). Here we cover the read paths against the -// real Management API: the fresh project shows up in `projects list`, and -// `projects api-keys` returns its keys. -describe("projects (live)", () => { - testLive( - "list includes the project and api-keys returns the anon key", - async ({ run, projectRef }) => { - const listed = await run(["projects", "list", "--output", "json"]); - expect(listed.exitCode, listed.stderr).toBe(0); - const refs = (JSON.parse(listed.stdout) as Array<{ id?: string; ref?: string }>).map( - (p) => p.ref ?? p.id, - ); - expect(refs).toContain(projectRef); - - const keys = await run([ - "projects", - "api-keys", - "--project-ref", - projectRef, - "--output", - "json", - ]); - expect(keys.exitCode, keys.stderr).toBe(0); - // Accept either a legacy anon JWT or a new-style publishable key — projects - // that only issue new keys still return a usable key. - const rows = JSON.parse(keys.stdout) as Array<{ name?: string; api_key?: string }>; - const hasUsableKey = rows.some( - (k) => k.name === "anon" || k.api_key?.startsWith("sb_publishable_"), - ); - expect(hasUsableKey, "expected an anon or publishable key").toBe(true); - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts deleted file mode 100644 index b5c2170b68..0000000000 --- a/apps/cli-e2e/src/tests/live/secrets.live.e2e.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -interface SecretRow { - name: string; -} - -// Live secrets flow (Management API only — no Docker, no DB). The fresh per-run -// project isolates the secret; the unset at the end cleans it up. Asserts on the -// real remote outcome: the key appears in `secrets list` after set and is gone -// after unset. -describe("secrets", () => { - testLive("set surfaces the key in list, unset removes it", async ({ run, projectRef }) => { - const key = "LIVE_E2E_SECRET"; - - const set = await run(["secrets", "set", `${key}=live-value`, "--project-ref", projectRef]); - expect(set.exitCode, set.stderr).toBe(0); - expect(set.stdout).toContain("Finished"); - - const afterSet = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(afterSet.exitCode, afterSet.stderr).toBe(0); - const setNames = (JSON.parse(afterSet.stdout) as SecretRow[]).map((s) => s.name); - expect(setNames).toContain(key); - - const unset = await run(["secrets", "unset", key, "--project-ref", projectRef, "--yes"]); - expect(unset.exitCode, unset.stderr).toBe(0); - expect(unset.stdout).toContain("Finished"); - - const afterUnset = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(afterUnset.exitCode, afterUnset.stderr).toBe(0); - const unsetNames = (JSON.parse(afterUnset.stdout) as SecretRow[]).map((s) => s.name); - expect(unsetNames).not.toContain(key); - }); -}); diff --git a/apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts deleted file mode 100644 index 2d705f690d..0000000000 --- a/apps/cli-e2e/src/tests/live/storage.live.e2e.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import { testLive } from "./live-context.ts"; - -// Storage object round-trip against the project's real Storage API. `storage -// --linked` opens a DB connection to resolve storage config; the direct host is -// IPv6-only (unreachable from IPv4-only CI), so we `link` first (with the db -// password) to persist the IPv4 pooler connection that storage then reuses. -// The bucket is pre-seeded by live-setup; storage is gated behind --experimental. -const STORAGE_FLAGS = ["--linked", "--experimental"]; -describe("storage (live --linked)", () => { - testLive( - "uploads, lists, and removes an object", - async ({ run, workspace, projectRef, storageBucket, dbPassword }) => { - const linked = await run(["link", "--project-ref", projectRef], { - env: { SUPABASE_DB_PASSWORD: dbPassword }, - }); - expect(linked.exitCode, linked.stderr).toBe(0); - - const local = join(workspace.path, "upload.txt"); - writeFileSync(local, "live-e2e storage payload\n"); - const remote = `ss:///${storageBucket}/upload.txt`; - - const cp = await run(["storage", "cp", local, remote, ...STORAGE_FLAGS]); - expect(cp.exitCode, cp.stderr).toBe(0); - - // Trailing slash lists the bucket's contents (without it, ls returns the - // bucket entry itself). - const ls = await run(["storage", "ls", `ss:///${storageBucket}/`, ...STORAGE_FLAGS]); - expect(ls.exitCode, ls.stderr).toBe(0); - expect(ls.stdout).toContain("upload.txt"); - - // --yes: rm prompts (default No) and would otherwise skip deletion in the - // non-TTY harness yet still exit 0. - const rm = await run(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); - expect(rm.exitCode, rm.stderr).toBe(0); - - // Confirm the object is actually gone (guards against a no-op delete). - const after = await run(["storage", "ls", `ss:///${storageBucket}/`, ...STORAGE_FLAGS]); - expect(after.exitCode, after.stderr).toBe(0); - expect(after.stdout).not.toContain("upload.txt"); - }, - ); -}); diff --git a/apps/cli-e2e/tests/live-setup.ts b/apps/cli-e2e/tests/live-setup.ts deleted file mode 100644 index 4672cf8a2e..0000000000 --- a/apps/cli-e2e/tests/live-setup.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { ProvidedContext } from "vitest"; -import { - isAccessTokenProvided, - isLive, - KEEP_PROJECT, - ORG_ID_OVERRIDE, - PROJECT_HOST, - TARGET, - TARGET_API_URL, -} from "../src/tests/env.ts"; -import { - createStorageBucket, - createTestProject, - deleteTestProject, - generateDbPassword, - getAnonKey, - getPoolerSessionUrl, - getServiceRoleKey, - resolveOrgId, - waitForProjectReady, -} from "./staging-project.ts"; -import "./provided-context.ts"; // centralized `inject()` key augmentation - -const STORAGE_BUCKET = "cli-e2e-live-bucket"; - -// Live e2e global setup (ADR-0013). Provisions ONE ephemeral project per run, -// wired straight at the real Management API — no replay server. Intentionally -// dumb: no provisioning retry (the CI job re-runs the whole step on flake). -export async function setup({ - provide, -}: { - provide: (key: K, value: ProvidedContext[K]) => void; -}) { - if (!isLive) { - // The live config was invoked without CLI_E2E_MODE=live. Every test is - // skipIf(!isLive), so provision nothing. - return () => {}; - } - if (!isAccessTokenProvided) { - throw new Error( - "Live mode requires a staging access token: set SUPABASE_ACCESS_TOKEN " + - "(or SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN). Refusing to provision against an empty token.", - ); - } - if (!PROJECT_HOST) { - throw new Error("CLI_E2E_PROJECT_HOST is required in live mode (function invoke host)"); - } - - // Resolving the org via `orgs list` also exercises that command against the - // real API; CLI_E2E_ORG_ID short-circuits it when set. - const orgId = ORG_ID_OVERRIDE ?? (await resolveOrgId(TARGET_API_URL)); - - // Per-job, per-run unique name so the CI cleanup can target only this job's - // project (never a sibling matrix job's). - const runId = process.env["GITHUB_RUN_ID"] ?? String(Date.now()); - const name = `cli-e2e-live-${TARGET}-${runId}-${randomUUID().slice(0, 8)}`; - - // Generated here (not a shared export) and routed through provide() so the - // password reaches tests only via inject(), never an importable module const. - const dbPassword = generateDbPassword(); - const projectRef = await createTestProject(TARGET_API_URL, orgId, name, dbPassword); - - // Once the project exists, any later setup failure must still delete it — - // setup returns before the teardown closure, so Vitest cannot clean up. - let anonKey: string; - let functionsUrl: string; - let dbUrl: string; - try { - await waitForProjectReady(TARGET_API_URL, projectRef); - anonKey = await getAnonKey(TARGET_API_URL, projectRef); - functionsUrl = `https://${projectRef}.${PROJECT_HOST}/functions/v1`; - // IPv4 session-mode pooler — the direct host is IPv6-only (unreachable from - // IPv4-only CI runners); the pooler is IPv4 and session mode supports pg_dump. - dbUrl = await getPoolerSessionUrl(TARGET_API_URL, projectRef, dbPassword); - // Seed a private bucket via the Storage API so the storage live tests have - // something to cp/ls/rm against (cleaned up with the project on teardown). - const serviceRoleKey = await getServiceRoleKey(TARGET_API_URL, projectRef); - await createStorageBucket(PROJECT_HOST, projectRef, serviceRoleKey, STORAGE_BUCKET); - } catch (err) { - // Delete the half-provisioned project, but never mask the original failure. - if (!KEEP_PROJECT) { - await deleteTestProject(TARGET_API_URL, projectRef, { throwOnError: true }).catch( - (cleanupErr) => console.error("Failed to delete project after setup failure:", cleanupErr), - ); - } - throw err; - } - - provide("projectRef", projectRef); - provide("anonKey", anonKey); - provide("functionsUrl", functionsUrl); - provide("dbUrl", dbUrl); - provide("dbPassword", dbPassword); - provide("storageBucket", STORAGE_BUCKET); - - return async () => { - if (KEEP_PROJECT) { - console.log(`CLI_E2E_KEEP_PROJECT set — leaving project ${projectRef} (${name}) alive`); - return; - } - // Surface a failed teardown so a leaked staging project is visible locally - // (CI also has the always() sweep as a backstop). - await deleteTestProject(TARGET_API_URL, projectRef, { throwOnError: true }); - }; -} diff --git a/apps/cli-e2e/tests/provided-context.ts b/apps/cli-e2e/tests/provided-context.ts index a02d045184..a498890840 100644 --- a/apps/cli-e2e/tests/provided-context.ts +++ b/apps/cli-e2e/tests/provided-context.ts @@ -1,15 +1,14 @@ -// Single source of truth for Vitest's `inject()` keys across all three modes -// (replay/record use the replay-server keys; live uses the staging-project keys). -// Both global setups import this module so the augmentation is always in the -// build and `inject("…")` is typed without `as` casts. +// Single source of truth for Vitest's `inject()` keys used by the replay/record +// harness. The global setup imports this module so the augmentation is always +// in the build and `inject("…")` is typed without `as` casts. export {}; declare module "vitest" { export interface ProvidedContext { - // Shared by every mode. + // Shared by replay and record. projectRef: string; storageBucket: string; - // Replay/record only (replay server + pg/docker mocks). + // Replay/record (replay server + pg/docker mocks). replayServerUrl: string; orgId: string; pgMockPort: number; @@ -17,14 +16,5 @@ declare module "vitest" { * In record mode the relay forwards to the real Docker socket; in replay * mode it serves recorded Docker API fixtures. */ dockerHostUrl: string; - // Live only (ADR-0013): real ephemeral project wiring. - /** Legacy anon JWT for invoking deployed functions over HTTP. */ - anonKey: string; - /** https://{ref}.{CLI_E2E_PROJECT_HOST}/functions/v1 */ - functionsUrl: string; - /** IPv4 session-pooler Postgres URL for --db-url DB commands. */ - dbUrl: string; - /** DB password of the ephemeral project (for `link` → persisted pooler config). */ - dbPassword: string; } } diff --git a/apps/cli-e2e/tests/staging-project.ts b/apps/cli-e2e/tests/staging-project.ts index e0d54017fb..d30d0c8bd8 100644 --- a/apps/cli-e2e/tests/staging-project.ts +++ b/apps/cli-e2e/tests/staging-project.ts @@ -2,28 +2,20 @@ import { randomBytes } from "node:crypto"; import { createHarness, exec } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, REGION, TARGET } from "../src/tests/env.ts"; -// Shared staging-project helpers used by both record setup (tests/setup.ts) and -// live setup (tests/live-setup.ts). -// -// `apiUrl` is whatever the CLI talks to: in record mode that is the replay -// server (so calls are captured); in live mode it is the real Management API -// (CLI_E2E_API_URL). The harness target + token come from env. +// Shared staging-project helpers used by record setup (tests/setup.ts). +// `apiUrl` is the replay server URL, which proxies calls to staging while +// recording. The harness target + token come from env. function harness(apiUrl: string) { return createHarness(TARGET, { apiUrl, accessToken: ACCESS_TOKEN }); } const PROJECT_REF_RE = /^[a-z]{20}$/; - -// Project statuses from which provisioning never recovers — fast-fail instead of -// polling to the timeout. const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); -/** A DB password for a throwaway project, used at creation and to build the live - * --db-url. Randomised per call (overridable via CLI_E2E_DB_PASSWORD) so no - * static credential is committed — the project is deleted on teardown anyway. - * Each setup generates its own and routes it through provide()/inject() rather - * than sharing a module-level export. */ +/** A DB password for a throwaway recording project. Randomised per call + * (overridable via CLI_E2E_DB_PASSWORD) so no static credential is committed — + * the project is deleted on teardown anyway. */ export function generateDbPassword(): string { return process.env["CLI_E2E_DB_PASSWORD"] ?? `cli-e2e-${randomBytes(12).toString("hex")}`; } @@ -64,8 +56,8 @@ export async function createTestProject( return ref; } -// `throwOnError` surfaces a failed deletion (live teardown uses it so a leaked -// staging project fails the run loudly; record setup keeps the lenient default). +// `throwOnError` surfaces deletion failures when a caller needs to fail loudly; +// record setup keeps the lenient default. export async function deleteTestProject( apiUrl: string, projectRef: string, @@ -100,8 +92,7 @@ export async function cleanupProjectsByName(apiUrl: string, names: string[]): Pr } } -/** Poll the real Management API until the project is ACTIVE_HEALTHY. Hits the API - * directly (not via any proxy) — this is setup-only and must not be recorded. */ +/** Poll the Management API until the recording project is ACTIVE_HEALTHY. */ export async function waitForProjectReady( apiBaseUrl: string, projectRef: string, @@ -121,155 +112,9 @@ export async function waitForProjectReady( ); } } else { - await res.body?.cancel(); // free the socket before sleeping + await res.body?.cancel(); } await new Promise((r) => setTimeout(r, 5_000)); } throw new Error(`Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`); } - -interface ApiKey { - name?: string; - api_key?: string; -} - -/** Resolve a key for invoking the project's deployed functions over HTTP. - * Prefers the legacy `anon` JWT: Edge Functions default to verify_jwt=true and - * a publishable (sb_publishable_) key is NOT a JWT, so it fails the platform - * JWT check on a verified function. Falls back to the publishable key for - * projects that only issue new-style keys. Even after ACTIVE_HEALTHY the - * api-keys endpoint can briefly 4xx, so retry. */ -export async function getAnonKey( - apiBaseUrl: string, - projectRef: string, - attempts = 12, -): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}/api-keys`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const keys = (await res.json()) as ApiKey[]; - const anonJwt = keys.find((k) => k.name === "anon" && k.api_key)?.api_key; - if (anonJwt) return anonJwt; - // Keys present but no legacy anon JWT. A publishable (sb_publishable_) key - // is NOT a JWT and 401s on the default verify_jwt=true functions, so fail - // loudly rather than proceed with a key that can't authenticate verified - // invokes (the suite would need to deploy with --no-verify-jwt instead). - if (keys.length > 0) { - throw new Error( - `Project ${projectRef} returned no anon JWT (only new-style keys); verified-function invokes require a JWT`, - ); - } - } else if (attempt < attempts) { - await res.body?.cancel(); // free the socket before sleeping - } - if (attempt === attempts) { - const detail = res.bodyUsed ? res.status : await res.text().catch(() => res.status); - throw new Error( - `Failed to resolve anon key for ${projectRef} after ${attempts} attempts: ${detail}`, - ); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - // Unreachable — the loop either returns a key or throws on the last attempt. - throw new Error(`Failed to resolve anon key for ${projectRef}`); -} - -/** Service-role / secret key, used to seed a storage bucket for the live storage - * tests (the same way record setup does). Retries like getAnonKey. */ -export async function getServiceRoleKey( - apiBaseUrl: string, - projectRef: string, - attempts = 12, -): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}/api-keys`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const keys = (await res.json()) as ApiKey[]; - const secret = - keys.find((k) => k.name === "service_role" && k.api_key)?.api_key ?? - keys.find((k) => k.api_key?.startsWith("sb_secret_"))?.api_key; - if (secret) return secret; - } else { - await res.body?.cancel(); // free the socket before sleeping - } - if (attempt === attempts) { - throw new Error(`Failed to resolve service-role key for ${projectRef}`); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - throw new Error(`Failed to resolve service-role key for ${projectRef}`); -} - -/** Create a private storage bucket via the project's Storage API (host derived - * from projectHost, IPv4-reachable). Idempotent — treats an existing bucket as - * success. */ -export async function createStorageBucket( - projectHost: string, - projectRef: string, - serviceRoleKey: string, - bucket: string, -): Promise { - const res = await fetch(`https://${projectRef}.${projectHost}/storage/v1/bucket`, { - method: "POST", - headers: { Authorization: `Bearer ${serviceRoleKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ id: bucket, name: bucket, public: false }), - }); - if (!res.ok && res.status !== 409) { - throw new Error(`Failed to create bucket ${bucket}: ${res.status} ${await res.text()}`); - } -} - -interface PoolerConfig { - database_type?: string; - connection_string?: string; -} - -/** Build a SESSION-mode (port 5432) Supavisor pooler connection string for the - * project's Postgres. The direct host (db....) is IPv6-only and unreachable - * from IPv4-only CI runners, so DB commands go through the pooler, which is IPv4. - * Session mode (not the API's default transaction 6543) is required for pg_dump - * (`db dump`). - * - * Reuses the Management API's `connection_string` verbatim — it carries tenant - * routing (e.g. options=reference=... query params) that a field-reconstructed - * URL would drop — and only swaps in our password and the session port. Mirrors - * the Go connector by selecting the PRIMARY pooler config. */ -export async function getPoolerSessionUrl( - apiBaseUrl: string, - projectRef: string, - password: string, - attempts = 12, -): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}/config/database/pooler`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const raw = (await res.json()) as PoolerConfig | PoolerConfig[]; - const configs = Array.isArray(raw) ? raw : [raw]; - const primary = configs.find((c) => c.database_type === "PRIMARY") ?? configs[0]; - if (primary?.connection_string) { - const url = new URL(primary.connection_string); - url.password = password; // overwrites the [YOUR-PASSWORD] placeholder (URL-encoded) - url.port = "5432"; // session mode (API returns the 6543 transaction port) - if (!url.searchParams.has("connect_timeout")) url.searchParams.set("connect_timeout", "30"); - return url.toString(); - } - } else if (attempt < attempts) { - await res.body?.cancel(); // free the socket before sleeping - } - if (attempt === attempts) { - const detail = res.bodyUsed ? res.status : await res.text().catch(() => res.status); - throw new Error( - `Failed to resolve pooler config for ${projectRef} after ${attempts} attempts: ${detail}`, - ); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - // Unreachable — the loop either returns a URL or throws on the last attempt. - throw new Error(`Failed to resolve pooler config for ${projectRef}`); -} diff --git a/apps/cli-e2e/vitest.config.ts b/apps/cli-e2e/vitest.config.ts index 74c9117ecc..bb87f884aa 100644 --- a/apps/cli-e2e/vitest.config.ts +++ b/apps/cli-e2e/vitest.config.ts @@ -5,10 +5,7 @@ export default defineConfig({ test: { passWithNoTests: true, include: ["**/*.e2e.test.ts"], - // Live tests are *.live.e2e.test.ts and run only via vitest.live.config.ts. - // They also match the include glob, so exclude them here to keep the - // PR-blocking replay suite from globbing them. - exclude: ["**/node_modules/**", "**/*.live.e2e.test.ts"], + exclude: ["**/node_modules/**"], fileParallelism: false, maxWorkers: 1, globalSetup: ["tests/setup.ts"], diff --git a/apps/cli-e2e/vitest.live.config.ts b/apps/cli-e2e/vitest.live.config.ts deleted file mode 100644 index 5a6ea689ff..0000000000 --- a/apps/cli-e2e/vitest.live.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from "vitest/config"; - -// Live e2e project (ADR-0013): runs *.live.e2e.test.ts against a real backend. -// Separate from vitest.config.ts so the PR-blocking replay suite never globs -// live tests. The replay server is NOT started here — live-setup wires the CLI -// straight at the real Management API + Docker socket. -export default defineConfig({ - test: { - passWithNoTests: true, - include: ["**/*.live.e2e.test.ts"], - fileParallelism: false, - maxWorkers: 1, - globalSetup: ["tests/live-setup.ts"], - // Real provisioning + Docker bundling are slow; give each test plenty of room. - testTimeout: 600_000, - hookTimeout: 600_000, - // Per-test flake (a single invoke/deploy blip) retries here; provisioning / - // setup flake is handled by the CI job re-running the whole step. - retry: 2, - }, -}); diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 4b32693b00..a48cd0a384 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -471,7 +471,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. -- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests whose asserted command reaches a real Supabase platform or project data plane — see "Live tests" below. ### Testing policy @@ -489,19 +489,44 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that ### Live tests (`*.live.test.ts`) -Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. - -- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. -- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. -- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: - - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal. - - `describeDockerLive` — the configured-live gate composed with a `docker info` probe; use for local-stack suites whose scenarios additionally need a reachable Docker daemon at collection time. It never runs on a machine that merely exposes Docker — `SUPABASE_ACCESS_TOKEN` must still be set, so the file stays inert outside the cli-e2e-ci runner like every other live suite. - - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). - - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). -- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. -- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. -- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. -- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). +Live tests are black-box CLI subprocess tests whose asserted command reaches a +real Management API, its suite-owned project, or that project's data plane. +They are serial, explicit, and expensive; keep them to one golden path per +command. The file name selects the live Vitest project and the file imports one +extended fixture as `test` from `tests/helpers/live.ts`: + +```ts +import { expect } from "vitest"; +import { test } from "../../../../../tests/helpers/live.ts"; + +test("lists projects", async ({ cli, project }) => { + const result = await cli(["projects", "list", "--output-format", "json"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(project.ref); +}); +``` + +Global setup requires `SUPABASE_LIVE_API_URL` and `SUPABASE_ACCESS_TOKEN`, +provisions one disposable project through the typed Effect `@supabase/api` +client, waits for `ACTIVE_HEALTHY`, resolves project wiring, writes a temporary +YAML profile, and shares it across the serial suite. Teardown deletes exactly +that project and the temporary profile. Supabox, a Docker-hosted API platform, +and staging are interchangeable; changing the URL and token retargets the +run. `SUPABASE_LIVE_KEEP_PROJECT=1` keeps the project for debugging. +`SUPABASE_LIVE_API_URL` configures the Management API only; tenant data-plane +URLs retain the profile contract `https://.`, with +`project_host` derived from the provisioned project's database host. + +Local Docker-stack lifecycle tests (`start`, `stop`, `status`, `db start`, +`db diff`, declarative sync, and `functions dev`) are `*.e2e.test.ts`, use +`runSupabase` plus the existing e2e stack cleanup, and require no platform +credentials. `functions deploy` remains live because its assertion is remote +deployment and invocation, even though Docker is a runner prerequisite. + +Setup/teardown may invoke other commands, but assertions stay focused on the +one command named by the test. The live workflow runs one serial attempt with a +20-minute bound, retains Docker preflight, and sweeps only projects owned by +that run after crashes. --- diff --git a/apps/cli/live.env.example b/apps/cli/live.env.example new file mode 100644 index 0000000000..a0ccbe76c9 --- /dev/null +++ b/apps/cli/live.env.example @@ -0,0 +1,17 @@ +# Live CLI e2e environment. The suite provisions one disposable project +# against the configured Management API URL. Supabox, Docker-hosted API, and +# staging use the same contract; only this URL and token change. Tenant data +# plane URLs remain https://., derived from the project DB +# host returned by the Management API. +SUPABASE_LIVE_API_URL=http://localhost:8080 +SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Optional provisioning/debug values. +# SUPABASE_LIVE_ORG_ID=... +# SUPABASE_LIVE_REGION=us-east-1 +# SUPABASE_LIVE_PROJECT_NAME=supabase-cli-live +# SUPABASE_LIVE_KEEP_PROJECT=1 +# NODE_EXTRA_CA_CERTS=/path/to/supabox/ca.pem + +# Run explicitly (Docker is required by the runner): +# pnpm test:live diff --git a/apps/cli/package.json b/apps/cli/package.json index c92c1b8695..69df5ea063 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,6 +34,7 @@ "dev:legacy": "pnpm exec bun src/legacy/main.ts", "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name --coverage.enabled", + "test:live": "bun --bun vitest run --project live", "test:smoke": "bun run tests/smoke-test.ts", "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" diff --git a/.github/scripts/sweep-live-projects.sh b/apps/cli/scripts/sweep-live-projects.sh similarity index 80% rename from .github/scripts/sweep-live-projects.sh rename to apps/cli/scripts/sweep-live-projects.sh index 19456281ed..39774ff381 100755 --- a/.github/scripts/sweep-live-projects.sh +++ b/apps/cli/scripts/sweep-live-projects.sh @@ -3,18 +3,18 @@ # e2e job's per-run prefix). Shared by the in-run retry sweep (called best-effort # with `|| true`) and the always() cleanup step (which propagates the exit code). # -# Reads SUPABASE_ACCESS_TOKEN + CLI_E2E_API_URL from the environment. Exits +# Reads SUPABASE_ACCESS_TOKEN + SUPABASE_LIVE_API_URL from the environment. Exits # non-zero if any DELETE failed; a failed *listing* also exits non-zero (pipefail). set -o pipefail PREFIX="${1:?usage: sweep-live-projects.sh PREFIX}" : "${SUPABASE_ACCESS_TOKEN:?SUPABASE_ACCESS_TOKEN required}" -: "${CLI_E2E_API_URL:?CLI_E2E_API_URL required}" +: "${SUPABASE_LIVE_API_URL:?SUPABASE_LIVE_API_URL required}" # Capture the list in a var (not a pipe-to-while subshell) so a failed delete is # recorded in $failed; a failed listing aborts here via pipefail. refs=$(curl -fsS -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${CLI_E2E_API_URL}/v1/projects" \ + "${SUPABASE_LIVE_API_URL}/v1/projects" \ | jq -r --arg p "$PREFIX" '.[] | select(.name|startswith($p)) | .ref // .id') failed=0 @@ -22,7 +22,7 @@ for ref in $refs; do [ -n "$ref" ] || continue echo "deleting leftover project $ref" if ! curl -fsS -X DELETE -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${CLI_E2E_API_URL}/v1/projects/${ref}" >/dev/null; then + "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" >/dev/null; then echo "::error::failed to delete leftover project $ref" failed=1 fi diff --git a/apps/cli/src/legacy/commands/branches/create/create.live.test.ts b/apps/cli/src/legacy/commands/branches/create/create.live.test.ts new file mode 100644 index 0000000000..3a4b1fd4fd --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/create/create.live.test.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function cleanupBranch( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const deleted = await cli(["branches", "delete", name, "--project-ref", ref, "--yes"]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + throw new Error( + `branches delete cleanup failed (exit ${deleted.exitCode})\n${deleted.stdout}\n${deleted.stderr}`, + ); + } +} + +test("creates a preview branch", async ({ cli, project }) => { + const name = `cli-e2e-create-${randomUUID().slice(0, 8)}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const result = await cli(["branches", "create", name, "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Created preview branch"); + } catch (error) { + targetError = error; + } finally { + try { + await cleanupBranch(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts b/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts new file mode 100644 index 0000000000..3bd193d878 --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts @@ -0,0 +1,47 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +test("deletes a preview branch", async ({ cli, project }) => { + const name = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; + let mayExist = false; + let targetError: unknown; + let cleanupError: unknown; + try { + mayExist = true; + const created = await cli(["branches", "create", name, "--project-ref", project.ref]); + requireLiveSuccess(created, "branches create"); + + const removed = await cli(["branches", "delete", name, "--project-ref", project.ref, "--yes"]); + if (removed.exitCode === 0) mayExist = false; + expect(removed.exitCode, removed.stderr).toBe(0); + expect(removed.stderr).toContain("Deleted preview branch"); + } catch (error) { + targetError = error; + } finally { + if (mayExist) { + try { + const cleanup = await cli([ + "branches", + "delete", + name, + "--project-ref", + project.ref, + "--yes", + ]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + cleanupError = new Error( + `branches delete cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`, + ); + } + } catch (error) { + cleanupError = error; + } + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts index 65422ad51b..8ac0529fac 100644 --- a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts @@ -1,30 +1,50 @@ -import { expect, test } from "vitest"; +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; -import { - describeLiveProject, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 120_000; +test("lists a preview branch for the project", async ({ cli, project }) => { + const name = `cli-e2e-list-${randomUUID().slice(0, 8)}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli(["branches", "create", name, "--project-ref", project.ref]); + requireLiveSuccess(created, "branches create setup"); -// Project-scoped read-only scenario. Skipped unless SUPABASE_LIVE_PROJECT_REF is -// set — i.e. a project has been provisioned on the stack (the cli-e2e-ci runner -// does this; a control-plane-only stack, like local macOS, skips it). -// -// Entry point for the branching lifecycle tracked in CLI-1834 -// (create / switch / delete) — extend here once a provisioned project is -// available on the full stack. -describeLiveProject("supabase branches list (live)", () => { - test("lists branches for the project", { timeout: LIVE_TIMEOUT_MS }, async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive([ + const result = await cli([ "branches", "list", + "--output", + "json", "--project-ref", - ref, + project.ref, ]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - }); + expect(result.exitCode, result.stderr).toBe(0); + const branches = JSON.parse(result.stdout) as Array<{ name?: string }>; + expect(branches.map((branch) => branch.name)).toContain(name); + } catch (error) { + targetError = error; + } finally { + try { + const deleted = await cli([ + "branches", + "delete", + name, + "--project-ref", + project.ref, + "--yes", + ]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + cleanupError = new Error( + `branches delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`, + ); + } + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts new file mode 100644 index 0000000000..2d8a044702 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts @@ -0,0 +1,115 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describe } from "vitest"; +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../../tests/helpers/cli.ts"; + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const DIFF_COMMAND_TIMEOUT_MS = 280_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const DIFF_TEST_TIMEOUT_MS = + CLI_COMMAND_TIMEOUT_MS + + STACK_START_TIMEOUT_MS + + CLI_COMMAND_TIMEOUT_MS * 2 + + DIFF_COMMAND_TIMEOUT_MS + + LIFECYCLE_MARGIN_MS; + +// CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally +// treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, +// so a declarative schema's `REVOKE ... FROM PUBLIC` on a function was silently +// dropped from the generated migration — exit code 0, no error, just a missing +// statement. Fixed upstream in @supabase/pg-delta@1.0.0-alpha.33 +// (supabase/pg-toolbelt#357). Verified directly against this repo's build: with +// the pre-fix pin (1.0.0-alpha.32) the migration below contains only the CREATE +// FUNCTION statement; the REVOKE is silently absent. This suite uses the local +// Docker-stack e2e coverage and never calls the Management API. See AGENTS.md's +// "E2e tests" section. +describe("supabase db diff (e2e, pg-delta declarative privileges)", () => { + let project: Awaited> | undefined; + + afterEach(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "keeps REVOKE ... FROM PUBLIC on a function when diffing a declarative schema against local", + { timeout: DIFF_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-db-diff-e2e-"); + const projectDir = project.dir; + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + // Exclude the heaviest, least relevant services — `db diff` only needs the + // local Postgres container reachable, same rationale as stop/status. + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + // Minimal, deterministic repro: execute a fresh function's implicit PUBLIC + // EXECUTE grant, explicitly revoked, directly against the local database. + // `db query` is setup only; keep each statement in its own invocation + // because the legacy query command sends one prepared statement at a time. + const createFunction = await runSupabase( + [ + "db", + "query", + `create function public.probe_fn() +returns void +language sql +as $$ select 1; $$;`, + "--local", + ], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS }, + ); + requireCliSuccess(createFunction, "db query create-function setup"); + + const revoke = await runSupabase( + ["db", "query", "revoke execute on function public.probe_fn() from public;", "--local"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS }, + ); + requireCliSuccess(revoke, "db query revoke setup"); + + const diff = await runSupabase( + ["db", "diff", "--local", "--use-pg-delta", "-f", "revoke_public_execute"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: DIFF_COMMAND_TIMEOUT_MS }, + ); + expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(0); + + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + const written = + existsSync(migrationsDir) && + readdirSync(migrationsDir).find((f) => f.endsWith("_revoke_public_execute.sql")); + expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); + const sql = readFileSync(path.join(migrationsDir, written as string), "utf8"); + + // The negative-space regression: pre-fix, exit code 0 and this file would + // exist, but silently missing the REVOKE statement (only the CREATE FUNCTION + // survives). Anchor the match to the function's own REVOKE statement — up to + // its terminating `;` — so this cannot pass on an unrelated PUBLIC mention + // elsewhere in the file. + expect(sql).toMatch( + /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i, + ); + expect(sql).toMatch( + /REVOKE\s+(?:ALL|EXECUTE)\s+ON\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)\s+FROM\s+[^;]*PUBLIC[^;]*;/i, + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts deleted file mode 100644 index 78614d9aa7..0000000000 --- a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import { afterEach, expect, test } from "vitest"; - -import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; - -const execFileAsync = promisify(execFile); - -const START_TIMEOUT_MS = 280_000; -// Lifecycle allowance for scenarios that run TWO full-budget subprocesses (`start` -// then the command under test) plus init/inspection overhead — same shape as -// `start.live.test.ts`. A single shared `START_TIMEOUT_MS` test budget would let a -// slow-but-valid `start` starve the command under test before it ever runs. -const LIFECYCLE_OVERHEAD_MS = 90_000; - -// CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally -// treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, -// so a declarative schema's `REVOKE ... FROM PUBLIC` on a function was silently -// dropped from the generated migration — exit code 0, no error, just a missing -// statement. Fixed upstream in @supabase/pg-delta@1.0.0-alpha.33 -// (supabase/pg-toolbelt#357). Verified directly against this repo's build: with -// the pre-fix pin (1.0.0-alpha.32) the migration below contains only the CREATE -// FUNCTION statement; the REVOKE is silently absent. `describeLive` is reused as -// the "real local Docker stack is available" signal, same as stop/status — this -// never calls the Management API. See AGENTS.md's "Live tests" section. -describeLive("supabase db diff (live, pg-delta declarative privileges)", () => { - let projectDir: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - }); - - test( - "keeps REVOKE ... FROM PUBLIC on a function when diffing a declarative schema against local", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-live-")); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - // `init`'s template already enables pg-delta by default (CLI-1877/#5511), but - // point `[db.migrations] schema_paths` at a declarative schema directory so - // `db diff --local` diffs against it instead of the (empty) local migration - // history. Paths are relative to `supabase/`. - const configPath = path.join(projectDir, "supabase", "config.toml"); - const config = readFileSync(configPath, "utf8"); - expect(config).toContain("schema_paths = []"); - writeFileSync( - configPath, - config.replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]'), - ); - - // Minimal, deterministic repro: a fresh function's implicit PUBLIC EXECUTE - // grant, explicitly revoked. Verified empirically against this build: pre-fix - // (pg-delta 1.0.0-alpha.32) the generated migration contains only the CREATE - // FUNCTION statement; the REVOKE is silently dropped. - const schemasDir = path.join(projectDir, "supabase", "schemas"); - mkdirSync(schemasDir, { recursive: true }); - writeFileSync( - path.join(schemasDir, "01_probe_fn.sql"), - `create function public.probe_fn() -returns void -language sql -as $$ select 1; $$; - -revoke execute on function public.probe_fn() from public; -`, - ); - - // Exclude the heaviest, least relevant services — `db diff` only needs the - // local Postgres container reachable, same rationale as stop/status. - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - const diff = await runSupabaseLive( - ["db", "diff", "--local", "--use-pg-delta", "-f", "revoke_public_execute"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(0); - - const migrationsDir = path.join(projectDir, "supabase", "migrations"); - const written = - existsSync(migrationsDir) && - readdirSync(migrationsDir).find((f) => f.endsWith("_revoke_public_execute.sql")); - expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); - const sql = readFileSync(path.join(migrationsDir, written as string), "utf8"); - - // The negative-space regression: pre-fix, exit code 0 and this file would - // exist, but silently missing the REVOKE statement (only the CREATE FUNCTION - // survives). Anchor the match to the function's own REVOKE statement — up to - // its terminating `;` — so this cannot pass on an unrelated PUBLIC mention - // elsewhere in the file. - expect(sql).toContain("CREATE FUNCTION public.probe_fn()"); - expect(sql).toMatch( - /REVOKE\s+(?:ALL|EXECUTE)\s+ON\s+FUNCTION\s+public\.probe_fn\(\)\s+FROM\s+[^;]*PUBLIC[^;]*;/i, - ); - }, - ); -}); - -// `--use-pgadmin` is a native `docker run` of the differ container, no -// edge-runtime and no Go delegation involved. Golden-path smoke coverage only — the -// pure filtering/progress logic and the docker-run argv are covered exhaustively by -// `legacy-pgadmin-diff.unit.test.ts` and `diff.integration.test.ts`; this just proves -// the real container actually runs against a real local stack and cleans up after -// itself either way. -// -// The real, reachable outcome here is a FAILURE, not a golden diff, by design: the -// differ container joins the project's own bridge network -// (`supabase_network_`), and both diff endpoints are hardcoded loopback -// URLs from that container's own point of view — `source` (resolving to `127.0.0.1` -// for a local target) and `target` -// (`postgresql://postgres:postgres@127.0.0.1:/postgres`). Inside a -// bridge-attached container, `127.0.0.1` is the container's OWN loopback, not the -// host's — so neither the local db nor the shadow is reachable from inside the -// differ, and the container exits non-zero. See `SIDE_EFFECTS.md`'s "Network -// reachability" entry for the full static ruling. (The historical value-receiver bug -// documented there — always reporting "No schema changes found" regardless of the -// differ's actual output — only ever engages when the differ container exits 0; it -// plays no role in this failure path.) Note that a plain `--network-id host` does NOT -// rescue a golden run here: it also rewires the SHADOW container onto host -// networking, discarding its own `54320->5432` port publish that `target` depends on -// — so `source` would become reachable but `target` would not, still failing the -// diff. This suite therefore verifies the real, always-reachable failure mode -// end-to-end, plus that both the differ AND the shadow container it provisions are -// still cleaned up. -describeLive("supabase db diff (live, --use-pgadmin native differ container)", () => { - let projectDir: string | undefined; - let projectId: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - projectId = undefined; - }); - - test( - "runs the native differ container against the real stack, surfaces Go's error running container failure, and leaves no differ container behind", - { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-pgadmin-live-")); - // No `project_id` override, so the cli resolves it from the workdir basename - // (see legacy-docker-ids.ts), same as `stop.live.test.ts`. - projectId = path.basename(projectDir); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - // Exclude the heaviest, least relevant services — `db diff --use-pgadmin` only - // needs the local Postgres container reachable, same rationale as stop/status. - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - const diff = await runSupabaseLive(["db", "diff", "--use-pgadmin"], { - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - // Both hardcoded loopback endpoints are unreachable from inside the - // bridge-attached differ container (see this suite's own header comment for the - // full, static ruling) — the differ exits non-zero and the CLI surfaces its own - // wrapper message. The differ's own exit code isn't pinned: only that the differ - // ran and failed, not the shadow/connection machinery around it. - expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(1); - expect(diff.stderr).toContain("error running container: exit "); - - // The differ is a one-shot `docker run --rm` — real Docker must agree that no - // container survives it, the same "the daemon must agree" check - // `stop.live.test.ts` runs against `com.supabase.cli.project`. - const { stdout: remainingDiffer } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - "ancestor=supabase/pgadmin-schema-diff:cli-0.0.5", - "--format", - "{{.ID}}", - ]); - expect(remainingDiffer.trim()).toBe(""); - - // This failure path exercises the shadow's `acquireUseRelease` teardown for - // real (the differ error propagates out of the `use` phase after the shadow was - // already created) — the shadow itself is created with no `--name` (Docker - // auto-generates one), unlike every real stack container, which is always named - // `supabase__`. So a leaked shadow shows up as a - // project-labeled container whose name does NOT carry that fixed prefix. - const { stdout: projectContainers } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - `label=com.supabase.cli.project=${projectId}`, - "--format", - "{{.Names}}", - ]); - const names = projectContainers - .trim() - .split("\n") - .filter((name) => name.length > 0); - expect(names.length).toBeGreaterThan(0); - expect(names.every((name) => name.startsWith("supabase_"))).toBe(true); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts index 8ebaaa8d63..8bf824a885 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts @@ -1,48 +1,12 @@ -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync } from "node:fs"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 300_000; - -// A fresh, isolated temp workdir so the CLI writes the dump there and never touches -// the repo tree. The provisioned project ref is supplied to `--linked` via the -// `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain (flag → -// `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); `config.toml`'s -// `project_id` is NOT consulted for `--linked`. -function tempWorkdir(): string { - return mkdtempSync(join(tmpdir(), "sb-db-dump-live-")); -} - -// Data-plane: needs a provisioned project whose database is routable (the -// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project -// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB -// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is -// skipped rather than timing out on pg_dump. -describeLiveDataPlane("supabase db dump (live)", () => { - test("dumps the linked project's schema to a file", { timeout: LIVE_TIMEOUT_MS }, async () => { - const ref = requireLiveProjectRef(); - const dir = tempWorkdir(); - try { - const outFile = join(dir, "schema.sql"); - const { exitCode, stdout, stderr } = await runSupabaseLive( - ["db", "dump", "--linked", "-f", outFile], - { cwd: dir, env: { SUPABASE_PROJECT_ID: ref }, exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000 }, - ); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - // The native pg_dump container (shared `legacyStreamPgDump`) opened + wrote - // the dump file. A fresh project's public schema may be near-empty, so assert - // the file was created rather than its size. - expect(existsSync(outFile)).toBe(true); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); +test("dumps the remote schema to a file", async ({ cli, project, workspace }) => { + const outFile = join(workspace.path, "schema.sql"); + const result = await cli(["db", "dump", "--db-url", project.dbUrl, "-f", outFile]); + expect(result.exitCode, result.stderr).toBe(0); + expect(existsSync(outFile)).toBe(true); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts index 320d274d60..bdb4119e38 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts @@ -1,69 +1,68 @@ -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 300_000; +test("pulls the remote schema after a local migration is applied", async ({ + cli, + project, + workspace, +}) => { + const version = `${Date.now()}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const existingMigrations = new Set(await readdir(migrations)); + const migrationFile = join(migrations, `${version}_e2e_pull.sql`); + await writeFile(migrationFile, `create table if not exists e2e_pull_${version} (id int);\n`); -// A fresh, isolated temp workdir so the CLI writes migrations there and never -// touches the repo tree. The provisioned project ref is supplied to `--linked` via -// the `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both -// Go and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); -// `config.toml`'s `project_id` is NOT consulted for `--linked`. -function tempWorkdir(): string { - return mkdtempSync(join(tmpdir(), "sb-db-pull-live-")); -} + let targetError: unknown; + try { + const pushed = await cli(["db", "push", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(pushed, "db push setup"); -// Data-plane: needs a provisioned project whose database is routable (the -// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project -// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB -// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is -// skipped rather than timing out on the pg_dump seed. -describeLiveDataPlane("supabase db pull (live)", () => { - test( - "initial pull from the linked project (native pg_dump seed + migra diff)", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const dir = tempWorkdir(); - try { - const { stdout, stderr, exitCode } = await runSupabaseLive(["db", "pull", "--linked"], { - cwd: dir, - env: { SUPABASE_PROJECT_ID: ref }, - exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000, - // Decline the "Update remote migration history table?" prompt with a piped - // `n`: this project ref is shared across live runs, and writing a - // `schema_migrations` row here would make a later run see it as an extra - // remote migration and fail with a history conflict before pulling. The - // piped answer also exercises the native prompt's stdin scanning end to end. - stdin: "n\n", - }); - const combined = `${stdout}${stderr}`; - expect(combined).not.toContain("Unauthorized"); - // No local migrations → the native initial-migra path runs: pg_dump the remote - // schema, then append the migra diff. Assert on the durable side effect: a - // provisioned project with schema writes a `_remote_schema.sql` - // migration; a fresh empty schema reports "No schema changes found". Either - // proves the path ran end to end against the real database without hanging. - const migDir = join(dir, "supabase", "migrations"); - const wroteMigration = - existsSync(migDir) && readdirSync(migDir).some((f) => f.endsWith("_remote_schema.sql")); - expect(wroteMigration || combined.includes("No schema changes found")).toBe(true); - // The native path creates the migration file BEFORE pg_dump runs, so a failed - // dump/diff could leave a stray file behind — a written migration is only - // meaningful if the command actually succeeded. - if (wroteMigration) { - expect(exitCode).toBe(0); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - ); + const result = await cli(["db", "pull", "--db-url", project.dbUrl, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}${result.stderr}`).not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + } catch (error) { + targetError = error; + } + + const cleanupErrors: Array = []; + // Remove all migrations created by this test before resetting. This + // includes both the seed migration and the migration generated by + // `db pull`; resetting with only the generated grant statements left + // behind can reference a table that no longer exists. + let currentMigrations: ReadonlyArray = []; + try { + currentMigrations = await readdir(migrations); + } catch (error) { + cleanupErrors.push(error); + } + for (const file of currentMigrations.filter((candidate) => !existingMigrations.has(candidate))) { + try { + await unlink(join(migrations, file)); + } catch (error) { + cleanupErrors.push( + new Error( + `db pull cleanup could not remove test migration ${join(migrations, file)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + ); + } + } + + try { + const reset = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(reset, "db reset cleanup after db pull"); + } catch (error) { + cleanupErrors.push(error); + } + + throwWithCleanup(targetError, cleanupErrors); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.live.test.ts b/apps/cli/src/legacy/commands/db/push/push.live.test.ts new file mode 100644 index 0000000000..d4d4ed890f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.live.test.ts @@ -0,0 +1,34 @@ +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +test("pushes a local migration to the remote database", async ({ cli, project, workspace }) => { + const version = `${Date.now()}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const migrationFile = join(migrations, `${version}_e2e_push.sql`); + await writeFile(migrationFile, `create table if not exists e2e_push_${version} (id int);\n`); + + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const result = await cli(["db", "push", "--db-url", project.dbUrl, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished supabase db push"); + } catch (error) { + targetError = error; + } finally { + await unlink(migrationFile).catch((error) => cleanupErrors.push(error)); + try { + const reset = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(reset, "db reset cleanup after db push"); + } catch (error) { + cleanupErrors.push(error); + } + } + throwWithCleanup(targetError, cleanupErrors); +}); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts new file mode 100644 index 0000000000..738446318c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts @@ -0,0 +1,34 @@ +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +test("resets the remote database with local migrations", async ({ cli, project, workspace }) => { + const version = `${Date.now()}${Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, "0")}`; + const migrations = join(workspace.path, "supabase", "migrations"); + await mkdir(migrations, { recursive: true }); + const migrationFile = join(migrations, `${version}_e2e_reset.sql`); + await writeFile(migrationFile, `create table if not exists e2e_reset_${version} (id int);\n`); + + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const result = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("Resetting remote database"); + } catch (error) { + targetError = error; + } finally { + await unlink(migrationFile).catch((error) => cleanupErrors.push(error)); + try { + const reset = await cli(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + requireLiveSuccess(reset, "db reset cleanup"); + } catch (error) { + cleanupErrors.push(error); + } + } + throwWithCleanup(targetError, cleanupErrors); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts new file mode 100644 index 0000000000..f39d724f50 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -0,0 +1,172 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterAll, beforeAll, expect, test } from "vitest"; + +import { describe } from "vitest"; +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../../../../tests/helpers/cli.ts"; + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const SCENARIO_COMMAND_TIMEOUT_MS = 280_000; +const BEFORE_ALL_TIMEOUT_MS = CLI_COMMAND_TIMEOUT_MS + STACK_START_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const SCENARIO_TIMEOUT_MS = 900_000; +const NEXT_ENV = { SUPABASE_USE_PG_DELTA_NEXT: "true" }; + +const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); + +create table public.disposable_note ( + id bigint generated by default as identity primary key, + body text not null +); + +create view public.auth_user_emails as +select id, email +from auth.users; +`; + +function commandFailure(result: { stdout: string; stderr: string }): string { + return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; +} + +function migrationFiles(projectDir: string): ReadonlyArray { + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + return existsSync(migrationsDir) + ? readdirSync(migrationsDir) + .filter((file) => file.endsWith(".sql")) + .sort() + : []; +} + +describe("db schema declarative sync (e2e)", () => { + let project: Awaited> | undefined; + + beforeAll(async () => { + project = await makeTempLegacyStackProject("sb-pgdelta-next-e2e-"); + const projectDir = project.dir; + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const config = readFileSync(configPath, "utf8"); + if (!config.includes("[experimental.pgdelta]\nenabled = true")) { + throw new Error("init setup did not enable experimental pg-delta in config.toml"); + } + writeFileSync( + configPath, + config.replace( + '# declarative_schema_path = "./schemas"', + 'declarative_schema_path = "./schemas"', + ), + ); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + writeFileSync(path.join(schemasDir, "public.sql"), initialDesiredSchema); + const extensionsDir = path.join(schemasDir, "cluster", "extensions"); + mkdirSync(extensionsDir, { recursive: true }); + for (const extension of ["pg_net", "pgcrypto", "uuid-ossp"]) { + writeFileSync( + path.join(extensionsDir, `${extension}.sql`), + `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";\n`, + ); + } + + const start = await runSupabase( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + }, BEFORE_ALL_TIMEOUT_MS); + + afterAll(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "applies a representative declarative schema and converges", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const projectDir = project?.dir; + if (projectDir === undefined) throw new Error("declarative sync project was not initialized"); + + const sync = await runSupabase( + [ + "db", + "schema", + "declarative", + "sync", + "--no-apply", + "--name", + "initial_declarative", + "--experimental", + ], + { + entrypoint: "legacy", + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }, + ); + expect(sync.exitCode, commandFailure(sync)).toBe(0); + + const migrations = migrationFiles(projectDir); + expect(migrations.length).toBeGreaterThan(0); + const sql = migrations + .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) + .join("\n"); + expect(sql).toContain("account_state"); + expect(sql).toContain("disposable_note"); + expect(sql).toContain("auth_user_emails"); + expect(sql).toContain("auth.users"); + expect(sql).not.toMatch( + /CREATE\s+(?:SCHEMA|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(?:auth|storage|realtime)["']?/iu, + ); + + const reset = await runSupabase(["db", "reset", "--local", "--no-seed"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(reset, "db reset setup"); + + const converged = await runSupabase( + ["db", "schema", "declarative", "sync", "--no-apply", "--experimental"], + { + entrypoint: "legacy", + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, + }, + ); + expect(converged.exitCode, commandFailure(converged)).toBe(0); + expect(`${converged.stdout}${converged.stderr}`).toContain("No schema changes found"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts deleted file mode 100644 index d6e5a33c77..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, expect, test } from "vitest"; - -import { describeDockerLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; - -const COMMAND_TIMEOUT_MS = 280_000; -const SCENARIO_TIMEOUT_MS = 900_000; -const NEXT_ENV = { SUPABASE_USE_PG_DELTA_NEXT: "true" }; - -const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); - -create table public.disposable_note ( - id bigint generated by default as identity primary key, - body text not null -); - -create view public.auth_user_emails as -select id, email -from auth.users; -`; - -function commandFailure(result: { stdout: string; stderr: string }): string { - return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; -} - -function migrationFiles(projectDir: string): ReadonlyArray { - const migrationsDir = path.join(projectDir, "supabase", "migrations"); - return existsSync(migrationsDir) - ? readdirSync(migrationsDir) - .filter((file) => file.endsWith(".sql")) - .sort() - : []; -} - -describeDockerLive("pg-delta next local convergence (live)", () => { - let projectDir = ""; - - beforeAll(async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-live-")); - - const init = await runSupabaseLive(["init"], { - cwd: projectDir, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }); - expect(init.exitCode, commandFailure(init)).toBe(0); - - const configPath = path.join(projectDir, "supabase", "config.toml"); - const config = readFileSync(configPath, "utf8"); - expect(config).toContain("schema_paths = []"); - expect(config).toContain("[experimental.pgdelta]\nenabled = true"); - writeFileSync( - configPath, - config - .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') - .replace( - '# declarative_schema_path = "./schemas"', - 'declarative_schema_path = "./schemas"', - ), - ); - - const schemasDir = path.join(projectDir, "supabase", "schemas"); - mkdirSync(schemasDir, { recursive: true }); - writeFileSync(path.join(schemasDir, "public.sql"), initialDesiredSchema); - - const start = await runSupabaseLive( - [ - "start", - "--exclude", - "studio", - "--exclude", - "logflare", - "--exclude", - "vector", - "--exclude", - "gotrue", - "--exclude", - "realtime", - "--exclude", - "storage-api", - ], - { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, - ); - expect(start.exitCode, commandFailure(start)).toBe(0); - }, COMMAND_TIMEOUT_MS); - - afterAll(async () => { - if (projectDir.length === 0) return; - await runSupabaseLive(["stop", "--no-backup"], { - cwd: projectDir, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - }, COMMAND_TIMEOUT_MS); - - test( - "applies a representative declarative schema and converges", - { timeout: SCENARIO_TIMEOUT_MS }, - async () => { - expect(migrationFiles(projectDir)).toEqual([]); - - const diff = await runSupabaseLive( - ["db", "diff", "--local", "--use-pg-delta", "-f", "initial_declarative"], - { cwd: projectDir, env: NEXT_ENV, exitTimeoutMs: COMMAND_TIMEOUT_MS }, - ); - expect(diff.exitCode, commandFailure(diff)).toBe(0); - - const migrations = migrationFiles(projectDir); - expect(migrations.length).toBeGreaterThan(0); - const sql = migrations - .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) - .join("\n"); - expect(sql).toContain("account_state"); - expect(sql).toContain("disposable_note"); - expect(sql).toContain("auth_user_emails"); - expect(sql).toContain("auth.users"); - expect(sql).not.toMatch( - /CREATE\s+(?:SCHEMA|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(?:auth|storage|realtime)["']?/iu, - ); - - const reset = await runSupabaseLive(["db", "reset", "--local", "--no-seed"], { - cwd: projectDir, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }); - expect(reset.exitCode, commandFailure(reset)).toBe(0); - - const converged = await runSupabaseLive(["db", "diff", "--local", "--use-pg-delta"], { - cwd: projectDir, - env: NEXT_ENV, - exitTimeoutMs: COMMAND_TIMEOUT_MS, - }); - expect(converged.exitCode, commandFailure(converged)).toBe(0); - expect(converged.stderr).toContain("No schema changes found"); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts new file mode 100644 index 0000000000..46ffcec0b3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; + +import { + makeTempHome, + makeTempStackProject, + runSupabase, +} from "../../../../../tests/helpers/cli.ts"; + +const DB_START_COMMAND_TIMEOUT_MS = 480_000; +const DB_START_CLEANUP_TIMEOUT_MS = 120_000; +const DB_START_TEST_TIMEOUT_MS = DB_START_COMMAND_TIMEOUT_MS + DB_START_CLEANUP_TIMEOUT_MS; + +describe("supabase db start (e2e)", () => { + test( + "boots the local database", + async () => { + const home = makeTempHome(); + const project = await makeTempStackProject("supabase-db-start-e2e-"); + try { + const started = await runSupabase(["db", "start"], { + entrypoint: "legacy", + cwd: project.dir, + home: home.dir, + exitTimeoutMs: DB_START_COMMAND_TIMEOUT_MS, + }); + expect(started.exitCode, started.stderr).toBe(0); + expect(`${started.stdout}${started.stderr}`).toMatch( + /Starting database|Initialising schema/i, + ); + } finally { + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: project.dir, + home: home.dir, + exitTimeoutMs: DB_START_CLEANUP_TIMEOUT_MS, + }).catch(() => undefined); + } + }, + DB_START_TEST_TIMEOUT_MS, + ); +}); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts new file mode 100644 index 0000000000..9b4b06f88c --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { expect } from "vitest"; + +import { test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function cleanupFunction( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + slug: string, + ref: string, +): Promise { + const deleted = await cli(["functions", "delete", slug, "--project-ref", ref]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + throw new Error(`functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`); + } +} + +test("deletes a deployed function", async ({ cli, project, workspace }) => { + const slug = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; + const directory = `${workspace.path}/supabase/functions/${slug}`; + await mkdir(directory, { recursive: true }); + await writeFile(`${directory}/index.ts`, "Deno.serve(() => Response.json({ ok: true }));\n"); + await writeFile(`${directory}/deno.json`, '{\n "imports": {}\n}\n'); + + let targetError: unknown; + let cleanupError: unknown; + try { + const deployed = await cli([ + "functions", + "deploy", + slug, + "--project-ref", + project.ref, + "--use-api", + ]); + if (deployed.exitCode !== 0) { + throw new Error( + `functions deploy setup failed (exit ${deployed.exitCode})\nstdout:\n${deployed.stdout}\nstderr:\n${deployed.stderr}`, + ); + } + + const result = await cli(["functions", "delete", slug, "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Deleted Function"); + } catch (error) { + targetError = error; + } finally { + try { + await cleanupFunction(cli, slug, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts new file mode 100644 index 0000000000..76e83ced69 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { describe } from "vitest"; + +import { expectFunctionOk, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function cleanupFunction( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + slug: string, + ref: string, +): Promise { + const deleted = await cli(["functions", "delete", slug, "--project-ref", ref]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + throw new Error(`functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`); + } +} + +describe("functions deploy (live)", () => { + test("deploys a function that responds over HTTP", async ({ + cli, + invoke, + project, + workspace, + }) => { + const slug = `cli-e2e-deploy-${randomUUID().slice(0, 8)}`; + const directory = join(workspace.path, "supabase", "functions", slug); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "index.ts"), + `Deno.serve(() => Response.json({ case: ${JSON.stringify(slug)}, ok: true }));\n`, + ); + await writeFile(join(directory, "deno.json"), '{\n "imports": {}\n}\n'); + + let targetError: unknown; + let cleanupError: unknown; + try { + const result = await cli(["functions", "deploy", "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toMatch(/Deployed Function/i); + + expectFunctionOk(await invoke(slug), slug); + } catch (error) { + targetError = error; + } finally { + try { + await cleanupFunction(cli, slug, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts index 2bfa93b86f..801dbd4906 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts @@ -1,52 +1,26 @@ -import { expect, test } from "vitest"; +import { describe, expect } from "vitest"; -import { - describeLive, - describeLiveProject, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 120_000; -// Project-scoped read-only scenario. Skipped unless SUPABASE_LIVE_PROJECT_REF is -// set — i.e. a project has been provisioned on the stack (the cli-e2e-ci runner -// does this; a control-plane-only stack, like local macOS, skips it). -// // This is the entry point for the broader edge-functions coverage tracked in // CLI-1834 (deploy + invoke over :443 / {ref}.supabase.red), which needs the // project's gateway reachable from the host — author those here as they become // runnable on the full stack. -describeLiveProject("supabase functions list (live)", () => { - test("lists edge functions for the project", { timeout: LIVE_TIMEOUT_MS }, async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive([ - "functions", - "list", - "--project-ref", - ref, - ]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - }); -}); - -// Project-scoped error path that needs NO provisioned project: a valid token -// with an unknown `--project-ref` must reach the live Management API, come back -// 404, and surface as a non-zero exit (not a crash, not "Unauthorized"). This -// exercises the `--project-ref` request path + error mapping on a control-plane- -// only stack, so it runs under `describeLive`, not `describeLiveProject`. -describeLive("supabase functions list — unknown project (live)", () => { - test("fails with a 404 for an unknown project ref", { timeout: LIVE_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive([ - "functions", - "list", - "--project-ref", - "a".repeat(20), // well-formed (20 lowercase chars) but nonexistent ref - ]); - const out = `${stdout}${stderr}`; - expect(exitCode).not.toBe(0); - expect(out).not.toContain("Unauthorized"); - expect(out).toContain("404"); - }); +describe("supabase functions list (live)", () => { + test( + "lists edge functions for the project", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli, project }) => { + const { exitCode, stdout, stderr } = await cli([ + "functions", + "list", + "--project-ref", + project.ref, + ]); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.live.test.ts b/apps/cli/src/legacy/commands/gen/types/types.live.test.ts new file mode 100644 index 0000000000..ac1acc30fe --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.live.test.ts @@ -0,0 +1,9 @@ +import { expect } from "vitest"; + +import { test } from "../../../../../tests/helpers/live.ts"; + +test("generates TypeScript types from the remote schema", async ({ cli, project }) => { + const result = await cli(["gen", "types", "--db-url", project.dbUrl, "--lang", "typescript"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toMatch(/export type (Database|Json)/); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts new file mode 100644 index 0000000000..c747836609 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts @@ -0,0 +1,9 @@ +import { expect } from "vitest"; + +import { test } from "../../../../../../tests/helpers/live.ts"; + +test("reports statistics from the remote database", async ({ cli, project }) => { + const result = await cli(["inspect", "db", "db-stats", "--db-url", project.dbUrl]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Database Size"); +}); diff --git a/apps/cli/src/legacy/commands/link/link.live.test.ts b/apps/cli/src/legacy/commands/link/link.live.test.ts new file mode 100644 index 0000000000..47bf065f1d --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.live.test.ts @@ -0,0 +1,12 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { test } from "../../../../tests/helpers/live.ts"; + +test("links a project and writes its workspace cache", async ({ cli, project, workspace }) => { + const result = await cli(["link", "--project-ref", project.ref, "--skip-pooler"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect(existsSync(join(workspace.path, "supabase", ".temp", "linked-project.json"))).toBe(true); +}); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts index 377ca8c033..48fa94e903 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts @@ -1,29 +1,25 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 120_000; -// A deterministic migration to seed into the remote history and fetch back. -const VERSION = "20240101000000"; -const NAME = "cli_live_roundtrip"; -const MIGRATION_FILE = `${VERSION}_${NAME}.sql`; +// A uniquely named migration to seed into the remote history and fetch back. +const NAME = "cli_live_fetch"; -// Data-plane scenario (Postgres over the pooler) — see the note in -// `../list/list.live.test.ts`. `describeLiveDataPlane` runs this only when the -// project instance is ACTIVE_HEALTHY (the full stack with supabase-postgres-17); -// it SKIPS on the control-plane-only CI that omits it (CLI-1825). +function liveMigrationVersion(): string { + return new Date().toISOString().replace(/\D/gu, "").slice(0, 14); +} + +// Destructive data-plane scenario (Postgres over the pooler) — the setup repairs +// remote migration history and the teardown reverts that exact row. The fixture +// provisions one ACTIVE_HEALTHY project for the serial live suite. // -// Round-trip: `migration fetch` reads the remote `schema_migrations` history and -// writes each row to `supabase/migrations/_.sql`; `migration list` -// then reads those files back as the Local column. +// Golden path: `migration fetch` reads the remote `schema_migrations` history and +// writes each row to `supabase/migrations/_.sql`. // // Unlike `migration list`, `migration fetch` does NOT tolerate a missing history // table: reading the migration table has no undefined-table fallback (only @@ -32,60 +28,67 @@ const MIGRATION_FILE = `${VERSION}_${NAME}.sql`; // (`relation … does not exist`). So we first SEED one migration into the remote // history via `migration repair --status applied` (which creates the migration // table then upserts the version from the local file), establishing -// the table + a row for `fetch` to read back. The ref is supplied via -// SUPABASE_PROJECT_ID. The seed is idempotent (upsert) and the supabox stack is torn -// down per run, so it leaves no shared state behind. -describeLiveDataPlane("supabase migration fetch (live)", () => { - test( - "seeds remote history, fetches it back, and lists it (round-trip)", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-seed-live-")); - const fetchDir = await mkdtemp(path.join(tmpdir(), "sb-migration-fetch-live-")); - try { - // Seed: record one migration in the remote history. `repair --status applied` - // reads the local file for the version's name/statements, so write it first. - await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); - await writeFile( - path.join(seedDir, "supabase", "migrations", MIGRATION_FILE), - "create table if not exists public.cli_live_roundtrip (id int);\n", - ); - const repaired = await runSupabaseLive( - ["migration", "repair", VERSION, "--status", "applied"], - { cwd: seedDir, env: { SUPABASE_PROJECT_ID: ref } }, - ); - expect(`${repaired.stdout}${repaired.stderr}`).not.toContain("Unauthorized"); - expect(repaired.exitCode, `stdout:\n${repaired.stdout}\nstderr:\n${repaired.stderr}`).toBe( - 0, - ); - - // Fetch into a fresh (empty) dir so no overwrite prompt fires; it reads the - // remote history and writes _.sql. - const fetched = await runSupabaseLive(["migration", "fetch"], { - cwd: fetchDir, - env: { SUPABASE_PROJECT_ID: ref }, - }); - expect(`${fetched.stdout}${fetched.stderr}`).not.toContain("Unauthorized"); - expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); +// the table + a row for `fetch` to read back. The shared fixture's pooler URL is +// passed explicitly so the test does not fall back to a direct IPv6 host. +test( + "fetches a seeded remote migration into the local migrations directory", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli, project }) => { + const targetArgs = ["--db-url", project.dbUrl]; + const version = liveMigrationVersion(); + const migrationFile = `${version}_${NAME}.sql`; + const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-seed-live-")); + const fetchDir = await mkdtemp(path.join(tmpdir(), "sb-migration-fetch-live-")); + let targetError: unknown; + const cleanupErrors: Array = []; + try { + // Seed: record one migration in the remote history. `repair --status applied` + // reads the local file for the version's name/statements, so write it first. + await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); + await writeFile( + path.join(seedDir, "supabase", "migrations", migrationFile), + "create table if not exists public.cli_live_roundtrip (id int);\n", + ); + const repairResult = await cli( + ["migration", "repair", version, "--status", "applied", ...targetArgs], + { cwd: seedDir }, + ); + requireLiveSuccess(repairResult, "migration repair setup"); - // fetch wrote the seeded migration back, under its established filename format. - const files = await readdir(path.join(fetchDir, "supabase", "migrations")); - expect(files).toContain(MIGRATION_FILE); + // Fetch into a fresh (empty) dir so no overwrite prompt fires; it reads the + // remote history and writes _.sql. + const fetched = await cli(["migration", "fetch", ...targetArgs], { cwd: fetchDir }); + expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); - // The same dir feeds `migration list` as the Local column — exit 0 and the - // fetched version is reflected back. - const listed = await runSupabaseLive(["migration", "list"], { - cwd: fetchDir, - env: { SUPABASE_PROJECT_ID: ref }, - }); - expect(`${listed.stdout}${listed.stderr}`).not.toContain("Unauthorized"); - expect(listed.exitCode, `stdout:\n${listed.stdout}\nstderr:\n${listed.stderr}`).toBe(0); - expect(listed.stdout).toContain(VERSION); - } finally { - await rm(seedDir, { recursive: true, force: true }); - await rm(fetchDir, { recursive: true, force: true }); + // fetch wrote the seeded migration back, under its established filename format. + const files = await readdir(path.join(fetchDir, "supabase", "migrations")); + expect(files).toContain(migrationFile); + } catch (error) { + targetError = error; + } finally { + try { + const reverted = await cli( + ["migration", "repair", version, "--status", "reverted", ...targetArgs], + { cwd: seedDir }, + ); + if ( + reverted.exitCode !== 0 && + !/not found|does not exist/i.test(`${reverted.stdout}\n${reverted.stderr}`) + ) { + cleanupErrors.push( + new Error(`migration repair cleanup failed:\n${reverted.stdout}\n${reverted.stderr}`), + ); + } + } catch (error) { + cleanupErrors.push(error); } - }, - ); -}); + await rm(seedDir, { recursive: true, force: true }).catch((error) => + cleanupErrors.push(error), + ); + await rm(fetchDir, { recursive: true, force: true }).catch((error) => + cleanupErrors.push(error), + ); + } + throwWithCleanup(targetError, cleanupErrors); + }, +); diff --git a/apps/cli/src/legacy/commands/migration/list/list.live.test.ts b/apps/cli/src/legacy/commands/migration/list/list.live.test.ts index 358baff4d2..d2cc786163 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.live.test.ts @@ -1,53 +1,9 @@ -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { - describeLiveDataPlane, - requireLiveProjectRef, - runSupabaseLive, -} from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 120_000; - -// Data-plane scenario: unlike `functions`/`branches` list (Management-API -// reads), `migration list` connects to the project's *Postgres* over the pooler. -// `describeLiveDataPlane` runs this only when the project instance is -// ACTIVE_HEALTHY — i.e. the full stack with supabase-postgres-17. The current -// cli-e2e-ci CI omits it (CLI-1825), so the project record exists but its DB is -// unreachable, and this suite SKIPS there rather than failing (see the gate's -// note). It activates automatically once the data-plane is provisioned. -// -// The `--linked` default mints a temp login role via the Management API, then -// reads `supabase_migrations.schema_migrations`. On a freshly provisioned -// project the history table is absent, which the handler maps to an empty list -// (an undefined-table error), so the command still exits 0. The ref is -// supplied via SUPABASE_PROJECT_ID (migration commands resolve the linked ref -// from env / config.toml / ref-file, not a `--project-ref` flag). -describeLiveDataPlane("supabase migration list (live)", () => { - test( - "lists migrations on the linked project's database", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive(["migration", "list"], { - env: { SUPABASE_PROJECT_ID: ref }, - }); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0); - }, - ); - - test( - "emits machine-readable JSON with --output-format json", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const ref = requireLiveProjectRef(); - const { exitCode, stdout, stderr } = await runSupabaseLive( - ["migration", "list", "--output-format", "json"], - { env: { SUPABASE_PROJECT_ID: ref } }, - ); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0); - // stdout must be payload-only valid JSON in json mode (no spinner/log noise). - expect(() => JSON.parse(stdout)).not.toThrow(); - }, - ); +test("lists migrations from the remote database", async ({ cli, project }) => { + const result = await cli(["migration", "list", "--db-url", project.dbUrl]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).not.toContain("Unauthorized"); }); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts b/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts index 515e8a855d..2b4217489a 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts @@ -1,52 +1,19 @@ -import { expect, test } from "vitest"; -import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; +import { expect } from "vitest"; +import { test } from "../../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 60_000; -// Harness smoke for the `live` Vitest project: the canonical example of a live -// test. It exercises the full path — built binary → SUPABASE_PROFILE resolution +// Harness smoke for the live Vitest project: the canonical example of a live +// test. It exercises the full path — built binary → temporary profile resolution // → authenticated Management API request against the running platform — with a // read-only call, so it is safe to run repeatedly and creates no resources. // -// Gated by `describeLive`: skipped unless SUPABASE_ACCESS_TOKEN is set (the -// cli-e2e-ci runner provides supabox's seeded PAT). Broader lifecycle scenarios -// (projects, functions, branching, db, storage) build on this same harness. -describeLive("supabase orgs list (live)", () => { - test( - "lists organizations for the authenticated token", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive(["orgs", "list"]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); - }, - ); - - test( - "emits machine-readable JSON with --output-format json", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabaseLive([ - "orgs", - "list", - "--output-format", - "json", - ]); - expect(exitCode).toBe(0); - // stdout must be payload-only valid JSON in json mode (no spinner/log noise). - expect(() => JSON.parse(stdout)).not.toThrow(); - }, - ); - - // Negative path: a bad token must round-trip to the real Management API, come - // back 401, and surface as a non-zero exit with the upstream "Unauthorized" - // message — i.e. the cli's auth + error mapping work against the live stack, - // not just the golden path. Overrides only the token (profile stays set). - test("fails with Unauthorized for an invalid token", { timeout: LIVE_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive(["orgs", "list"], { - env: { SUPABASE_ACCESS_TOKEN: `sbp_${"0".repeat(40)}` }, - }); - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain("Unauthorized"); - }); -}); +test( + "lists organizations for the authenticated token", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli }) => { + const { exitCode, stdout, stderr } = await cli(["orgs", "list"]); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + }, +); diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts new file mode 100644 index 0000000000..b7b7ba7097 --- /dev/null +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts @@ -0,0 +1,19 @@ +import { expect } from "vitest"; + +import { test } from "../../../../../tests/helpers/live.ts"; + +test("lists API keys for a project", async ({ cli, project }) => { + const result = await cli([ + "projects", + "api-keys", + "--project-ref", + project.ref, + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(0); + const rows = JSON.parse(result.stdout) as Array<{ name?: string; api_key?: string }>; + expect( + rows.some((key) => key.name === "anon" || key.api_key?.startsWith("sb_publishable_")), + ).toBe(true); +}); diff --git a/apps/cli/src/legacy/commands/projects/list/list.live.test.ts b/apps/cli/src/legacy/commands/projects/list/list.live.test.ts index 8c4ca20f33..12db8ce9b7 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.live.test.ts @@ -1,33 +1,25 @@ -import { expect, test } from "vitest"; +import { expect } from "vitest"; -import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; +import { test } from "../../../../../tests/helpers/live.ts"; -const LIVE_TIMEOUT_MS = 60_000; - -// Account-level read-only live scenario, alongside `orgs list`. Lists every -// project the authenticated token can access — no project ref required, so it -// runs against just the control plane (no provisioned project instance needed). -// Safe to run repeatedly; creates nothing. -describeLive("supabase projects list (live)", () => { - test("lists projects for the authenticated token", { timeout: LIVE_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabaseLive(["projects", "list"]); - expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); - expect(exitCode).toBe(0); +test("lists the live project for the authenticated token", async ({ cli, project }) => { + const result = await cli(["projects", "list", "--output-format", "json"]); + expect(result.exitCode, result.stderr).toBe(0); + const parsed: unknown = JSON.parse(result.stdout); + expect(parsed).toEqual(expect.objectContaining({ projects: expect.any(Array) })); + if ( + parsed === null || + typeof parsed !== "object" || + !("projects" in parsed) || + !Array.isArray(parsed.projects) + ) { + throw new Error("projects list JSON response did not contain a projects array"); + } + const refs = parsed.projects.flatMap((project) => { + if (project === null || typeof project !== "object") return []; + if ("ref" in project && typeof project.ref === "string") return [project.ref]; + if ("id" in project && typeof project.id === "string") return [project.id]; + return []; }); - - test( - "emits machine-readable JSON with --output-format json", - { timeout: LIVE_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabaseLive([ - "projects", - "list", - "--output-format", - "json", - ]); - expect(exitCode).toBe(0); - // stdout must be payload-only valid JSON in json mode (no spinner/log noise). - expect(() => JSON.parse(stdout)).not.toThrow(); - }, - ); + expect(refs).toContain(project.ref); }); diff --git a/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts b/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts new file mode 100644 index 0000000000..1b5e062d9e --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function unsetSecret( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const cleanup = await cli(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + throw new Error(`secrets unset cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`); + } +} + +test("lists a secret created on the remote project", async ({ cli, project }) => { + const name = `CLI_E2E_LIST_${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli([ + "secrets", + "set", + `${name}=live-value`, + "--project-ref", + project.ref, + ]); + requireLiveSuccess(created, "secrets set setup"); + + const result = await cli(["secrets", "list", "--output", "json", "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + const names = (JSON.parse(result.stdout) as Array<{ name: string }>).map( + (secret) => secret.name, + ); + expect(names).toContain(name); + } catch (error) { + targetError = error; + } finally { + try { + await unsetSecret(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts new file mode 100644 index 0000000000..617ad85c8c --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function unsetSecret( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const cleanup = await cli(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + throw new Error(`secrets unset cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`); + } +} + +test("sets a secret on the remote project", async ({ cli, project }) => { + const name = `CLI_E2E_SET_${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const result = await cli([ + "secrets", + "set", + `${name}=live-value`, + "--project-ref", + project.ref, + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished"); + } catch (error) { + targetError = error; + } finally { + try { + await unsetSecret(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts new file mode 100644 index 0000000000..ede0a6dc6b --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts @@ -0,0 +1,47 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +async function unsetSecret( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + name: string, + ref: string, +): Promise { + const cleanup = await cli(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + if ( + cleanup.exitCode !== 0 && + !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) + ) { + throw new Error(`secrets unset cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`); + } +} + +test("unsets a secret from the remote project", async ({ cli, project }) => { + const name = `CLI_E2E_UNSET_${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli([ + "secrets", + "set", + `${name}=live-value`, + "--project-ref", + project.ref, + ]); + requireLiveSuccess(created, "secrets set setup"); + + const result = await cli(["secrets", "unset", name, "--project-ref", project.ref, "--yes"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Finished"); + } catch (error) { + targetError = error; + } finally { + try { + await unsetSecret(cli, name, project.ref); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts similarity index 80% rename from apps/cli/src/legacy/commands/start/start.live.test.ts rename to apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts index fcfd71f2a0..2bc0483e9c 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts @@ -5,9 +5,9 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; +import { requireCliSuccess, runSupabase } from "../../../../tests/helpers/cli.ts"; import { legacySanitizeProjectId, legacyServiceContainerName, @@ -20,21 +20,14 @@ import { dockerfileServiceImage } from "../../../shared/services/dockerfile-imag const execFileAsync = promisify(execFile); const START_TIMEOUT_MS = 280_000; -const SHORT_LIVE_TIMEOUT_MS = 30_000; +const SHORT_E2E_TIMEOUT_MS = 30_000; const LIFECYCLE_OVERHEAD_MS = 90_000; /** * `--exclude` values for the 3 heaviest/least-relevant services — same intent - * `stop.live.test.ts`/`status.live.test.ts` already documented for their own - * reduced-stack `start` call (Studio's Next.js build, the Logflare/Vector - * logging pipeline), but "logflare" here, NOT "analytics" like those two - * siblings. `LEGACY_SERVICE_CATALOG`'s `excludeKey` for the logflare service - * is "logflare" — "analytics" is only that service's *container suffix* - * (`legacy-service-catalog.ts`), never a valid `--exclude` value. The - * siblings' `--exclude analytics` is a silent no-op — harmless for their own - * coarse "is the stack up/down" assertions, but this suite's exact- - * container-set assertions need the genuinely valid key so logflare is - * actually excluded. + * as the reduced-stack `start` calls in the sibling Docker e2e suites (Studio's + * Next.js build and the Logflare/Vector logging pipeline). The legacy service + * catalog uses `logflare` as the exclusion key for that logging service. */ const EXCLUDED_SERVICE_KEYS: ReadonlySet = new Set(["studio", "logflare", "vector"]); @@ -42,10 +35,10 @@ const EXCLUDED_SERVICE_KEYS: ReadonlySet = new Set(["studio", "logflare" * Services the running-container assertion below must NOT expect to be running, even though * they are neither in `EXCLUDED_SERVICE_KEYS` nor `--exclude`d on the `start` call itself: * - `supavisor` — `db.pooler.enabled` defaults to `false` (`packages/config/src/db.ts`, - * `defaultPoolerEnabled`), and `runSupabaseLive(["init"], ...)` above writes a config.toml + * `defaultPoolerEnabled`), and `runSupabase(["init"], ...)` above writes a config.toml * with no override, so it's genuinely disabled on this test's stack, not merely unasserted. * - `imgproxy` — gated on `storage.image_transformation.enabled` (`start.gates.ts:169`), - * which defaults to `false`/absent; `runSupabaseLive(["init"], ...)` writes a config.toml + * which defaults to `false`/absent; `runSupabase(["init"], ...)` writes a config.toml * with `[storage.image_transformation]` still commented out * (`project-init.templates.ts:132-133`), so imgproxy is genuinely disabled on this test's stack. */ @@ -60,18 +53,21 @@ function splitNonEmptyLines(text: string): ReadonlyArray { // `start` is the one local-dev-stack command whose correctness genuinely // depends on a real Docker daemon — real label filtering and real container -// lifecycle, not just CLI exit codes. `describeLive` is reused purely as the -// "we're in the full cli-e2e-ci runner" signal (see stop.live.test.ts's own +// lifecycle, not just CLI exit codes. `describe` gates the +// "we're in a configured e2e runner" signal (see stop.e2e.test.ts's own // comment for why this, not a Management-API gate, is correct here). See -// AGENTS.md's "Live tests" section for the full convention. -describeLive("supabase start (live)", () => { +// AGENTS.md's "e2e tests" section for the full convention. +describe("supabase start (e2e)", () => { let projectDir: string | undefined; afterEach(async () => { if (projectDir === undefined) return; // Best-effort cleanup even if an assertion above failed mid-lifecycle — a // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: projectDir, + }).catch(() => undefined); await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); projectDir = undefined; }); @@ -80,7 +76,7 @@ describeLive("supabase start (live)", () => { "recreates a stopped real stack and preserves database data", { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-")); + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-")); // No `project_id` override, so the cli resolves it from the workdir // basename (see legacy-docker-ids.ts). Sanitizing is a no-op for a // `mkdtemp`-generated basename (already alphanumeric/`-`), but mirrors @@ -98,13 +94,15 @@ describeLive("supabase start (live)", () => { "vector", ]; - const init = await runSupabaseLive(["init"], { + const init = await runSupabase(["init"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + requireCliSuccess(init, "init setup"); - const start = await runSupabaseLive(startArgs, { + const start = await runSupabase(startArgs, { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, }); @@ -135,7 +133,7 @@ describeLive("supabase start (live)", () => { const containerIds = splitNonEmptyLines(containerIdOutput); expect(containerIds.length).toBeGreaterThan(0); await execFileAsync("docker", ["stop", "--time", "0", ...containerIds], { - timeout: SHORT_LIVE_TIMEOUT_MS, + timeout: SHORT_E2E_TIMEOUT_MS, }); const { stdout: stoppedState } = await execFileAsync("docker", [ @@ -150,7 +148,8 @@ describeLive("supabase start (live)", () => { Status: "exited", }); - const restart = await runSupabaseLive(startArgs, { + const restart = await runSupabase(startArgs, { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, }); @@ -195,11 +194,12 @@ describeLive("supabase start (live)", () => { ).toBe(!isExcluded); } - const status = await runSupabaseLive(["status"], { + const status = await runSupabase(["status"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + requireCliSuccess(status, "status setup"); }, ); @@ -207,13 +207,14 @@ describeLive("supabase start (live)", () => { "bypasses an HTTPS proxy for loopback gateway health checks", { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-proxy-")); + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-proxy-")); - const init = await runSupabaseLive(["init"], { + const init = await runSupabase(["init"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + requireCliSuccess(init, "init setup"); let proxyConnections = 0; const proxy = createServer((socket) => { @@ -237,7 +238,8 @@ describeLive("supabase start (live)", () => { : ["--exclude", entry.excludeKey], ); const proxyUrl = `http://127.0.0.1:${address.port}`; - const start = await runSupabaseLive(["start", ...excludeArgs], { + const start = await runSupabase(["start", ...excludeArgs], { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, env: { @@ -273,18 +275,19 @@ describeLive("supabase start (live)", () => { "names the container and its image when a cached image cannot be executed", { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-exec-")); + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-exec-")); const projectId = legacySanitizeProjectId(path.basename(projectDir)); const mailpitContainer = legacyServiceContainerName("inbucket", projectId); // The exact tag `start` resolves for Mailpit, so its already-cached check // finds this deliberately broken build and never reaches a registry. const mailpitImage = legacyGetRegistryImageUrl(dockerfileServiceImage("mailpit")); - const init = await runSupabaseLive(["init"], { + const init = await runSupabase(["init"], { + entrypoint: "legacy", cwd: projectDir, - exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + requireCliSuccess(init, "init setup"); // A `scratch` image whose entrypoint is not an executable binary — the // kernel refuses it with exactly the "exec format error" this diagnoses. @@ -305,7 +308,8 @@ describeLive("supabase start (live)", () => { ? [] : ["--exclude", entry.excludeKey], ); - const start = await runSupabaseLive(["start", ...excludeArgs], { + const start = await runSupabase(["start", ...excludeArgs], { + entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS, }); diff --git a/apps/cli/src/legacy/commands/status/status.e2e.test.ts b/apps/cli/src/legacy/commands/status/status.e2e.test.ts new file mode 100644 index 0000000000..5b4e47413d --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.e2e.test.ts @@ -0,0 +1,77 @@ +import { afterEach, expect, test } from "vitest"; + +import { describe } from "vitest"; +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const STATUS_COMMAND_TIMEOUT_MS = 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const STATUS_TEST_TIMEOUT_MS = + CLI_COMMAND_TIMEOUT_MS + + STACK_START_TIMEOUT_MS + + STATUS_COMMAND_TIMEOUT_MS * 2 + + LIFECYCLE_MARGIN_MS; + +// See stop.e2e.test.ts for why `describe` (not a Management-API gate) is +// the right reuse here: `status` never calls the Management API, only the real +// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "e2e tests" +// section for the full convention. +describe("supabase status (e2e)", () => { + let project: Awaited> | undefined; + + afterEach(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "reports a running local stack in pretty and json modes", + { timeout: STATUS_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-status-e2e-"); + const projectDir = project.dir; + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + const pretty = await runSupabase(["status"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STATUS_COMMAND_TIMEOUT_MS, + }); + expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); + expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); + expect(pretty.stdout).toContain("Project URL"); + expect(pretty.stdout).toContain("Database"); + + const json = await runSupabase(["status", "-o", "json"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STATUS_COMMAND_TIMEOUT_MS, + }); + expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); + const parsed: unknown = JSON.parse(json.stdout); + expect(parsed).toMatchObject({ + API_URL: expect.stringContaining("http"), + DB_URL: expect.stringContaining("postgresql://"), + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/status/status.live.test.ts b/apps/cli/src/legacy/commands/status/status.live.test.ts deleted file mode 100644 index 64569c118c..0000000000 --- a/apps/cli/src/legacy/commands/status/status.live.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, expect, test } from "vitest"; - -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; - -const START_TIMEOUT_MS = 280_000; - -// See stop.live.test.ts for why `describeLive` (not a Management-API gate) is -// the right reuse here: `status` never calls the Management API, only the real -// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "Live tests" -// section for the full convention. -describeLive("supabase status (live)", () => { - let projectDir: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - }); - - test( - "reports a running local stack in pretty and json modes", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-status-live-")); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - const pretty = await runSupabaseLive(["status"], { cwd: projectDir }); - expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); - expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); - expect(pretty.stdout).toContain("Project URL"); - expect(pretty.stdout).toContain("Database"); - - const json = await runSupabaseLive(["status", "-o", "json"], { cwd: projectDir }); - expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); - const parsed: unknown = JSON.parse(json.stdout); - expect(parsed).toMatchObject({ - API_URL: expect.stringContaining("http"), - DB_URL: expect.stringContaining("postgresql://"), - }); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts new file mode 100644 index 0000000000..6e18d79786 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts @@ -0,0 +1,175 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; + +import { + makeTempLegacyStackProject, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; +import { legacySanitizeProjectId } from "../../shared/legacy-docker-ids.ts"; + +const execFileAsync = promisify(execFile); + +const CLI_COMMAND_TIMEOUT_MS = 60_000; +const STACK_START_TIMEOUT_MS = 280_000; +const STOP_COMMAND_TIMEOUT_MS = 120_000; +const DOCKER_INSPECT_TIMEOUT_MS = 30_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const LIFECYCLE_MARGIN_MS = 30_000; +const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; +const STOP_TEST_TIMEOUT_MS = + CLI_COMMAND_TIMEOUT_MS + + STACK_START_TIMEOUT_MS + + CLI_COMMAND_TIMEOUT_MS + + STOP_COMMAND_TIMEOUT_MS + + DOCKER_INSPECT_TIMEOUT_MS + + LIFECYCLE_MARGIN_MS; + +// `stop` never calls the Management API — it talks directly to the real local +// Docker stack `start` creates. `describe` gates +// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a +// real Docker daemon, since that's how supabox itself runs); the +// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See +// AGENTS.md's "e2e tests" section for the full convention. +describe("supabase stop (e2e)", () => { + let project: Awaited> | undefined; + let projectId: string | undefined; + + afterEach(async () => { + await project?.cleanup().catch(() => undefined); + project = undefined; + projectId = undefined; + }, CLEANUP_HOOK_TIMEOUT_MS); + + test( + "starts a real local stack, then stops it and removes its containers", + { timeout: STOP_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-stop-e2e-"); + const projectDir = project.dir; + // No `project_id` override, so the cli resolves it from the workdir + // basename (see legacy-docker-ids.ts). + projectId = path.basename(projectDir); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + // Exclude the heaviest, least relevant services (Next.js Studio build, the + // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care + // which services are running, only that at least one real container + // exists to stop. + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + // Sanity: confirm the stack is actually up before testing `stop` against it. + const before = await runSupabase(["status"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(before, "status setup"); + + const stop = await runSupabase(["stop"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STOP_COMMAND_TIMEOUT_MS, + }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // The real Docker daemon must agree: no container carrying this project's + // label survives `stop` — the actual behavior under test, not just the + // cli's own exit code. + const { stdout: remaining } = await execFileAsync( + "docker", + [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ], + { timeout: DOCKER_INSPECT_TIMEOUT_MS }, + ); + expect(remaining.trim()).toBe(""); + }, + ); + + test( + "stop --no-backup --debug reports real pruned containers, volumes, and network", + { timeout: STOP_TEST_TIMEOUT_MS }, + async () => { + project = await makeTempLegacyStackProject("sb-stop-e2e-"); + const projectDir = project.dir; + // Sanitizing is a no-op for a `mkdtemp`-generated basename (already + // alphanumeric/`-`), but mirrors the port's actual resolution rather + // than assuming that stays true (same note as `start.e2e.test.ts`). + projectId = legacySanitizeProjectId(path.basename(projectDir)); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + + const start = await runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { entrypoint: "legacy", cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, + ); + requireCliSuccess(start, "start setup"); + + // `--no-backup` exercises the volume-prune branch; `--debug` turns on + // the `Pruned …:` stderr reports, which are + // backed by parsing REAL `docker`/`podman` prune stdout — the format + // assumption (`Deleted …:` headers, `Total reclaimed space:` trailer) + // that mocked integration fixtures cannot validate by construction. + const stop = await runSupabase(["stop", "--no-backup", "--debug"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: STOP_COMMAND_TIMEOUT_MS, + }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // Containers: real Docker reports full hex IDs — the list must be + // non-empty, since the started stack's containers were just removed. + expect(stop.stderr).toMatch(/^Pruned containers: \[[0-9a-f][^\]]*\]$/mu); + // Volumes: the db volume always exists (db is never excluded), so the + // report must name it. Other project volumes may also appear. + const volumesLine = stop.stderr + .split("\n") + .find((line) => line.startsWith("Pruned volumes: [")); + expect(volumesLine, `stderr:\n${stop.stderr}`).toContain(`supabase_db_${projectId}`); + // Network: exactly the project network; the established label is singular + // "network", unlike the other two reports. + expect(stop.stderr).toContain(`Pruned network: [supabase_network_${projectId}]`); + + // The real Docker daemon must agree with the report: nothing carrying + // this project's label survives. + const { stdout: remaining } = await execFileAsync( + "docker", + [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ], + { timeout: DOCKER_INSPECT_TIMEOUT_MS }, + ); + expect(remaining.trim()).toBe(""); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts deleted file mode 100644 index 12d71ed169..0000000000 --- a/apps/cli/src/legacy/commands/stop/stop.live.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import { afterEach, expect, test } from "vitest"; - -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; -import { legacySanitizeProjectId } from "../../shared/legacy-docker-ids.ts"; - -const execFileAsync = promisify(execFile); - -const START_TIMEOUT_MS = 280_000; - -// `stop` never calls the Management API — it talks directly to the real local -// Docker stack `start` creates. `describeLive` is reused -// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a -// real Docker daemon, since that's how supabox itself runs); the -// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See -// AGENTS.md's "Live tests" section for the full convention. -describeLive("supabase stop (live)", () => { - let projectDir: string | undefined; - let projectId: string | undefined; - - afterEach(async () => { - if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. - await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - projectDir = undefined; - projectId = undefined; - }); - - test( - "starts a real local stack, then stops it and removes its containers", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); - // No `project_id` override, so the cli resolves it from the workdir - // basename (see legacy-docker-ids.ts). - projectId = path.basename(projectDir); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - // Exclude the heaviest, least relevant services (Next.js Studio build, the - // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care - // which services are running, only that at least one real container - // exists to stop. - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - // Sanity: confirm the stack is actually up before testing `stop` against it. - const before = await runSupabaseLive(["status"], { cwd: projectDir }); - expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); - - const stop = await runSupabaseLive(["stop"], { cwd: projectDir }); - expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); - expect(stop.stdout).toContain("Stopped"); - - // The real Docker daemon must agree: no container carrying this project's - // label survives `stop` — the actual behavior under test, not just the - // cli's own exit code. - const { stdout: remaining } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - `label=com.supabase.cli.project=${projectId}`, - "--format", - "{{.ID}}", - ]); - expect(remaining.trim()).toBe(""); - }, - ); - - test( - "stop --no-backup --debug reports real pruned containers, volumes, and network", - { timeout: START_TIMEOUT_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); - // Sanitizing is a no-op for a `mkdtemp`-generated basename (already - // alphanumeric/`-`), but mirrors the port's actual resolution rather - // than assuming that stays true (same note as `start.live.test.ts`). - projectId = legacySanitizeProjectId(path.basename(projectDir)); - - const init = await runSupabaseLive(["init"], { cwd: projectDir }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - - // `--no-backup` exercises the volume-prune branch; `--debug` turns on - // the `Pruned …:` stderr reports, which are - // backed by parsing REAL `docker`/`podman` prune stdout — the format - // assumption (`Deleted …:` headers, `Total reclaimed space:` trailer) - // that mocked integration fixtures cannot validate by construction. - const stop = await runSupabaseLive(["stop", "--no-backup", "--debug"], { cwd: projectDir }); - expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); - expect(stop.stdout).toContain("Stopped"); - - // Containers: real Docker reports full hex IDs — the list must be - // non-empty, since the started stack's containers were just removed. - expect(stop.stderr).toMatch(/^Pruned containers: \[[0-9a-f][^\]]*\]$/mu); - // Volumes: the db volume always exists (db is never excluded), so the - // report must name it. Other project volumes may also appear. - const volumesLine = stop.stderr - .split("\n") - .find((line) => line.startsWith("Pruned volumes: [")); - expect(volumesLine, `stderr:\n${stop.stderr}`).toContain(`supabase_db_${projectId}`); - // Network: exactly the project network; the established label is singular - // "network", unlike the other two reports. - expect(stop.stderr).toContain(`Pruned network: [supabase_network_${projectId}]`); - - // The real Docker daemon must agree with the report: nothing carrying - // this project's label survives. - const { stdout: remaining } = await execFileAsync("docker", [ - "ps", - "-a", - "--filter", - `label=com.supabase.cli.project=${projectId}`, - "--format", - "{{.ID}}", - ]); - expect(remaining.trim()).toBe(""); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts new file mode 100644 index 0000000000..c66f82c981 --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +const STORAGE_FLAGS = ["--linked", "--experimental"]; + +async function removeObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + if ( + removed.exitCode !== 0 && + !/not found|does not exist/i.test(`${removed.stdout}\n${removed.stderr}`) + ) { + throw new Error(`storage rm cleanup failed:\n${removed.stdout}\n${removed.stderr}`); + } +} + +test("copies a local file to the remote bucket", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `upload-${suffix}.txt`); + const remote = `ss:///${project.storageBucket}/upload-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + let cleanupError: unknown; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage cp"); + + const result = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + expect(result.exitCode, result.stderr).toBe(0); + } catch (error) { + targetError = error; + } finally { + try { + await removeObject(cli, remote); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts new file mode 100644 index 0000000000..b678ceb7df --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +const STORAGE_FLAGS = ["--linked", "--experimental"]; + +async function removeObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + if ( + removed.exitCode !== 0 && + !/not found|does not exist/i.test(`${removed.stdout}\n${removed.stderr}`) + ) { + throw new Error(`storage rm cleanup failed:\n${removed.stdout}\n${removed.stderr}`); + } +} + +test("lists an uploaded object", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `upload-${suffix}.txt`); + const remote = `ss:///${project.storageBucket}/upload-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + let cleanupError: unknown; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage ls"); + const uploaded = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + requireLiveSuccess(uploaded, "storage cp setup for storage ls"); + + const result = await cli([ + "storage", + "ls", + `ss:///${project.storageBucket}/`, + ...STORAGE_FLAGS, + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(`upload-${suffix}.txt`); + } catch (error) { + targetError = error; + } finally { + try { + await removeObject(cli, remote); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts new file mode 100644 index 0000000000..674268a2cc --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { requireLiveSuccess, test, throwWithCleanup } from "../../../../../tests/helpers/live.ts"; + +const STORAGE_FLAGS = ["--linked", "--experimental"]; + +async function removeObject( + cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, + remote: string, +): Promise { + const removed = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + if ( + removed.exitCode !== 0 && + !/not found|does not exist/i.test(`${removed.stdout}\n${removed.stderr}`) + ) { + throw new Error(`storage rm cleanup failed:\n${removed.stdout}\n${removed.stderr}`); + } +} + +test("removes an uploaded object", async ({ cli, project, workspace }) => { + const suffix = randomUUID().slice(0, 8); + const local = join(workspace.path, `upload-${suffix}.txt`); + const remote = `ss:///${project.storageBucket}/upload-${suffix}.txt`; + await writeFile(local, "live-e2e storage payload\n"); + + let targetError: unknown; + let cleanupError: unknown; + try { + const linked = await cli(["link", "--project-ref", project.ref], { + env: { SUPABASE_DB_PASSWORD: project.dbPassword }, + }); + requireLiveSuccess(linked, "link setup for storage rm"); + const uploaded = await cli(["storage", "cp", local, remote, ...STORAGE_FLAGS]); + requireLiveSuccess(uploaded, "storage cp setup for storage rm"); + + const result = await cli(["storage", "rm", remote, "--yes", ...STORAGE_FLAGS]); + expect(result.exitCode, result.stderr).toBe(0); + } catch (error) { + targetError = error; + } finally { + try { + await removeObject(cli, remote); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts new file mode 100644 index 0000000000..be5905217b --- /dev/null +++ b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts @@ -0,0 +1,189 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { + makeTempCliProject, + makeTempHome, + runSupabase, + spawnSupabase, +} from "../../../../../tests/helpers/cli.ts"; +import { cleanupRegisteredStackProjects } from "../../../../../tests/helpers/stack-e2e-cleanup.ts"; + +const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 60_000; +const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000; +const FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS = 30_000; +const FUNCTIONS_DEV_TEST_TIMEOUT_MS = + FUNCTIONS_DEV_STARTUP_TIMEOUT_MS + + FUNCTIONS_DEV_STEP_TIMEOUT_MS * 7 + + FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS; +const FUNCTION_RESPONSE_ATTEMPT_TIMEOUT_MS = 5_000; +const FUNCTION_RESPONSE_RETRY_BACKOFF_MS = 250; +const FUNCTION_FILES_RESTART_PATTERN = /Function files changed\. Restarting edge-runtime\./; + +type SpawnedSupabase = ReturnType; + +async function assertFunctionResponse( + url: string, + init: RequestInit, + assertResponse: (response: Response, body: string) => void, + timeoutMs = FUNCTIONS_DEV_STEP_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastFailure: unknown = new Error("No response received"); + + while (Date.now() < deadline) { + const remainingMs = deadline - Date.now(); + try { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(Math.min(remainingMs, FUNCTION_RESPONSE_ATTEMPT_TIMEOUT_MS)), + }); + const body = await response.text(); + assertResponse(response, body); + return; + } catch (error) { + lastFailure = error; + // Bound request frequency while the worker catches up after a reload; + // the wall-clock deadline, rather than an attempt count, remains the guard. + const retryDelayMs = Math.min( + FUNCTION_RESPONSE_RETRY_BACKOFF_MS, + Math.max(0, deadline - Date.now()), + ); + if (retryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + } + + throw new Error( + `Function request ${url} did not reach the expected response within ${timeoutMs}ms. ` + + `Last failure: ${lastFailure instanceof Error ? lastFailure.message : String(lastFailure)}`, + ); +} + +describe("supabase functions dev (e2e)", () => { + afterEach(cleanupRegisteredStackProjects); + + test( + "serves a function created while running and applies config and source changes", + { timeout: FUNCTIONS_DEV_TEST_TIMEOUT_MS }, + async () => { + const home = makeTempHome(); + // The next functions runtime owns managed port allocation. This project + // intentionally contains no released-port reservations from the test. + const project = await makeTempCliProject("supabase-functions-dev-e2e-"); + await mkdir(join(project.dir, "supabase"), { recursive: true }); + await writeFile( + join(project.dir, "supabase", "config.toml"), + 'project_id = "functions-dev-e2e"\n', + ); + const functionPath = join(project.dir, "supabase", "functions", "hello-world", "index.ts"); + let devProc: SpawnedSupabase | undefined; + + try { + devProc = spawnSupabase(["functions", "dev"], { + cwd: project.dir, + home: home.dir, + cleanupProcessGroupOnClose: false, + exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, + }); + + await devProc.waitForOutput( + /Edge Functions dev server is running\./, + FUNCTIONS_DEV_STARTUP_TIMEOUT_MS, + ); + const functionUrlMatch = `${devProc.stdout()}\n${devProc.stderr()}`.match( + /Functions URL:\s+(https?:\/\/[^\s/]+\/functions\/v1)/, + ); + if (functionUrlMatch?.[1] === undefined) { + throw new Error( + `Functions dev output did not include a URL.\nstdout:\n${devProc.stdout()}\nstderr:\n${devProc.stderr()}`, + ); + } + const functionUrl = `${functionUrlMatch[1]}/hello-world`; + + const functionOffset = devProc.stdout().length; + const functionRestart = devProc.waitForOutput( + FUNCTION_FILES_RESTART_PATTERN, + FUNCTIONS_DEV_STEP_TIMEOUT_MS, + functionOffset, + ); + const newResult = await runSupabase(["functions", "new", "hello-world"], { + cwd: project.dir, + home: home.dir, + exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, + }); + expect(newResult.exitCode).toBe(0); + await functionRestart; + + await assertFunctionResponse(functionUrl, {}, (response, body) => { + expect(response.status).toBe(401); + expect(body).toContain("Missing authorization header"); + }); + + const configOffset = devProc.stdout().length; + const configRestart = devProc.waitForOutput( + FUNCTION_FILES_RESTART_PATTERN, + FUNCTIONS_DEV_STEP_TIMEOUT_MS, + configOffset, + ); + await writeFile( + join(project.dir, "supabase", "config.toml"), + `project_id = "functions-dev-e2e" + +[functions.hello-world] +verify_jwt = false +`, + ); + await configRestart; + + await assertFunctionResponse( + functionUrl, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Functions Dev" }), + }, + (response, body) => { + expect(response.status).toBe(200); + expect(JSON.parse(body)).toEqual({ message: "Hello Functions Dev!" }); + }, + ); + + const sourceOffset = devProc.stdout().length; + const sourceRestart = devProc.waitForOutput( + FUNCTION_FILES_RESTART_PATTERN, + FUNCTIONS_DEV_STEP_TIMEOUT_MS, + sourceOffset, + ); + await writeFile( + functionPath, + `Deno.serve(() => { + return new Response(JSON.stringify({ message: "Updated from source edit" }), { + headers: { "content-type": "application/json" }, + }); +}); +`, + ); + await sourceRestart; + + await assertFunctionResponse( + functionUrl, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Functions Dev" }), + }, + (response, body) => { + expect(response.status).toBe(200); + expect(JSON.parse(body)).toEqual({ message: "Updated from source edit" }); + }, + ); + } finally { + devProc?.kill("SIGTERM"); + await devProc?.waitForExit().catch(() => undefined); + } + }, + ); +}); diff --git a/apps/cli/src/next/commands/functions/dev/dev.live.test.ts b/apps/cli/src/next/commands/functions/dev/dev.live.test.ts deleted file mode 100644 index ddb5670c82..0000000000 --- a/apps/cli/src/next/commands/functions/dev/dev.live.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { afterEach, expect, test } from "vitest"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { - makeTempHome, - makeTempStackProject, - runSupabase, - spawnSupabase, -} from "../../../../../tests/helpers/cli.ts"; -import { describeLive } from "../../../../../tests/helpers/live.ts"; -import { cleanupRegisteredStackProjects } from "../../../../../tests/helpers/stack-e2e-cleanup.ts"; - -const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 60_000; -const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000; -const FUNCTIONS_DEV_TEST_TIMEOUT_MS = 90_000; -const FUNCTION_FILES_RESTART_PATTERN = /Function files changed\. Restarting edge-runtime\./; -const FUNCTION_FILES_RESTART_PATTERN_GLOBAL = /Function files changed\. Restarting edge-runtime\./g; - -type SpawnedSupabase = ReturnType; - -function countOutputMatches(proc: SpawnedSupabase, pattern: RegExp): number { - return [...`${proc.stdout()}\n${proc.stderr()}`.matchAll(pattern)].length; -} - -async function waitForOutputMatchCount( - proc: SpawnedSupabase, - pattern: RegExp, - expectedCount: number, -) { - const deadline = Date.now() + FUNCTIONS_DEV_STEP_TIMEOUT_MS; - - while (Date.now() < deadline) { - if (countOutputMatches(proc, pattern) >= expectedCount) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error( - `Timed out waiting for ${expectedCount.toString()} occurrences of ${pattern.toString()}`, - ); -} - -async function waitForFunctionResponse( - url: string, - init: RequestInit, - assertResponse: (response: Response, body: string) => void, -) { - const deadline = Date.now() + FUNCTIONS_DEV_STEP_TIMEOUT_MS; - let lastError: unknown; - - while (Date.now() < deadline) { - try { - const response = await fetch(url, init); - const body = await response.text(); - try { - assertResponse(response, body); - return; - } catch (error) { - lastError = error; - } - } catch (error) { - lastError = error; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw lastError instanceof Error - ? lastError - : new Error(`Timed out waiting for function response: ${String(lastError)}`); -} - -// This crosses the compiled CLI, detached supervisor, full local stack, file -// watcher, and HTTP runtime boundaries. Keep the one golden path in the -// opt-in live suite instead of slowing and destabilizing ordinary e2e shards. -describeLive("supabase functions dev (live)", () => { - afterEach(cleanupRegisteredStackProjects); - - test( - "serves a function created while running and applies live config and source changes", - { timeout: FUNCTIONS_DEV_TEST_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-functions-dev-e2e-"); - const functionPath = join(project.dir, "supabase", "functions", "hello-world", "index.ts"); - const functionUrl = `http://127.0.0.1:${project.ports.apiPort}/functions/v1/hello-world`; - let devProc: ReturnType | undefined; - - try { - devProc = spawnSupabase(["functions", "dev"], { - cwd: project.dir, - home: home.dir, - cleanupProcessGroupOnClose: false, - exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, - }); - - await devProc.waitForOutput( - /Edge Functions dev server is running\./, - FUNCTIONS_DEV_STARTUP_TIMEOUT_MS, - ); - await new Promise((resolve) => setTimeout(resolve, 500)); - - const newResult = await runSupabase(["functions", "new", "hello-world"], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, - }); - expect(newResult.exitCode).toBe(0); - - await devProc.waitForOutput(FUNCTION_FILES_RESTART_PATTERN, FUNCTIONS_DEV_STEP_TIMEOUT_MS); - - await waitForFunctionResponse(functionUrl, {}, (response, body) => { - expect(response.status).toBe(401); - expect(body).toContain("Missing authorization header"); - }); - - await writeFile( - join(project.dir, "supabase", "config.toml"), - `project_id = "functions-dev-e2e" - -[functions.hello-world] -verify_jwt = false -`, - ); - - await devProc.waitForOutput( - /Edge runtime config changed\. Restarting edge-runtime\./, - FUNCTIONS_DEV_STEP_TIMEOUT_MS, - ); - - await waitForFunctionResponse( - functionUrl, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Functions Dev" }), - }, - (response, body) => { - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ message: "Hello Functions Dev!" }); - }, - ); - - const restartCount = countOutputMatches(devProc, FUNCTION_FILES_RESTART_PATTERN_GLOBAL); - await writeFile( - functionPath, - `Deno.serve(() => { - return new Response(JSON.stringify({ message: "Updated from source edit" }), { - headers: { "content-type": "application/json" }, - }); -}); -`, - ); - await waitForOutputMatchCount( - devProc, - FUNCTION_FILES_RESTART_PATTERN_GLOBAL, - restartCount + 1, - ); - - await waitForFunctionResponse( - functionUrl, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Functions Dev" }), - }, - (response, body) => { - expect(response.status).toBe(200); - expect(JSON.parse(body)).toEqual({ message: "Updated from source edit" }); - }, - ); - } finally { - devProc?.kill("SIGTERM"); - await devProc?.waitForExit().catch(() => {}); - } - }, - ); -}); diff --git a/apps/cli/src/next/commands/start/start.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts new file mode 100644 index 0000000000..898331795e --- /dev/null +++ b/apps/cli/src/next/commands/start/start.e2e.test.ts @@ -0,0 +1,101 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { makeTempCliProject, makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; +import { cleanupRegisteredStackProjects } from "../../../../tests/helpers/stack-e2e-cleanup.ts"; + +const START_TIMEOUT_MS = 180_000; +const COMMAND_OPTIONS = { entrypoint: "next" as const }; +const LIGHTWEIGHT_DOCKER_ARGS = [ + "start", + "--detach", + "--mode", + "docker", + "--exclude", + "realtime", + "--exclude", + "storage", + "--exclude", + "imgproxy", + "--exclude", + "mailpit", + "--exclude", + "pgmeta", + "--exclude", + "studio", + "--exclude", + "analytics", + "--exclude", + "vector", + "--exclude", + "pooler", +] as const; + +// Lazy service activation crosses the real proxy, daemon, Docker network, and +// container lifecycle boundaries, so keep one golden-path Docker e2e test. +describe("supabase start lazy lifecycle (e2e)", () => { + let project: Awaited> | undefined; + let home: ReturnType | undefined; + + afterEach(async () => { + await cleanupRegisteredStackProjects(); + project = undefined; + home = undefined; + }); + + test( + "keeps an HTTP service dormant until its first proxied request", + { timeout: START_TIMEOUT_MS + 120_000 }, + async () => { + project = await makeTempCliProject("supabase-lazy-start-e2e-"); + home = makeTempHome(); + await mkdir(join(project.dir, "supabase"), { recursive: true }); + const projectId = basename(project.dir) + .replace(/[^a-z0-9]/giu, "") + .toLowerCase(); + await writeFile( + join(project.dir, "supabase", "config.toml"), + `project_id = "${projectId}"\n`, + ); + + const started = await runSupabase([...LIGHTWEIGHT_DOCKER_ARGS], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0); + + const before = await runSupabase(["status"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + expect(before.stdout).toContain("auth: Dormant"); + + const apiUrlMatch = + `${started.stdout}\n${started.stderr}\n${before.stdout}\n${before.stderr}`.match( + /API URL:\s+(https?:\/\/[^\s]+)/, + ); + if (apiUrlMatch?.[1] === undefined) { + throw new Error( + `Start/status output did not include an API URL.\nstdout:\n${before.stdout}\nstderr:\n${before.stderr}`, + ); + } + + const response = await fetch(`${apiUrlMatch[1]}/auth/v1/health`, { + signal: AbortSignal.timeout(60_000), + }); + expect(response.ok).toBe(true); + + const after = await runSupabase(["status"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }); + expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0); + expect(after.stdout).toContain("auth: Healthy"); + }, + ); +}); diff --git a/apps/cli/src/next/commands/start/start.live.test.ts b/apps/cli/src/next/commands/start/start.live.test.ts deleted file mode 100644 index f9183d5f16..0000000000 --- a/apps/cli/src/next/commands/start/start.live.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, expect, test } from "vitest"; -import { makeTempHome, makeTempStackProject } from "../../../../tests/helpers/cli.ts"; -import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; - -const START_TIMEOUT_MS = 180_000; -const COMMAND_OPTIONS = { entrypoint: "next" as const }; -const LIGHTWEIGHT_DOCKER_ARGS = [ - "start", - "--detach", - "--mode", - "docker", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -// Lazy service activation crosses the real proxy, daemon, Docker network, and -// container lifecycle boundaries, so keep one gated golden-path live test. -describeLive("supabase start lazy lifecycle (live)", () => { - let project: Awaited> | undefined; - let home: ReturnType | undefined; - - afterEach(async () => { - if (project !== undefined && home !== undefined) { - await runSupabaseLive(["stop", "--no-backup"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }).catch(() => undefined); - } - await project?.cleanup(); - home?.[Symbol.dispose](); - project = undefined; - home = undefined; - }); - - test( - "keeps an HTTP service dormant until its first proxied request", - { timeout: START_TIMEOUT_MS + 120_000 }, - async () => { - project = await makeTempStackProject("supabase-lazy-start-live-"); - home = makeTempHome(); - - const started = await runSupabaseLive([...LIGHTWEIGHT_DOCKER_ARGS], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0); - - const before = await runSupabaseLive(["status"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }); - expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); - expect(before.stdout).toContain("auth: Pending"); - - const response = await fetch(`http://127.0.0.1:${project.ports.apiPort}/auth/v1/health`, { - signal: AbortSignal.timeout(60_000), - }); - expect(response.ok).toBe(true); - - const after = await runSupabaseLive(["status"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }); - expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0); - expect(after.stdout).toContain("auth: Healthy"); - }, - ); -}); diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index 4da25f32c0..a25d1f13bf 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -72,45 +72,6 @@ describe("stack e2e cleanup manager", () => { expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); }); - it("keeps cleanup best-effort when the associated home cannot be disposed", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: [], - stackDirs: [], - trackedPids: [], - }), - }), - ); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - throw permissionError("home is not removable"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); - - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("/tmp/home")); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("home is not removable")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); - }); - it("canonicalizes symlinked project and home paths before matching stack state", async () => { const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-")); const project = join(root, "project"); @@ -151,6 +112,44 @@ describe("stack e2e cleanup manager", () => { } }); + it("preserves project and home cleanup receivers", async () => { + class ReceiverHome { + readonly dir = "/tmp/home"; + disposed = false; + + dispose() { + this.disposed = true; + } + } + + class ReceiverProject { + readonly dir = "/tmp/project"; + cleaned = false; + + async cleanup() { + this.cleaned = true; + } + } + + const home = new ReceiverHome(); + const project = new ReceiverProject(); + const manager = createStackE2eCleanupManager(cleanupEnvironment([])); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + manager.registerHome(home); + manager.registerStackProject(project); + manager.associateHome(project.dir, home.dir); + + await manager.drain(); + + expect(project.cleaned).toBe(true); + expect(home.disposed).toBe(true); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it("ignores non-stack homes", async () => { const calls: Array = []; const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); @@ -265,6 +264,102 @@ describe("stack e2e cleanup manager", () => { expect(calls).toEqual(["cleanup-project", "docker-remove"]); }); + it("removes permission-blocked associated homes with the Docker root fallback", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: async () => { + calls.push("docker-remove"); + return true; + }, + }), + ); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: async () => { + calls.push("cleanup-project"); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); + + await expect(manager.drain()).resolves.toBeUndefined(); + + expect(calls).toEqual(["cleanup-project", "dispose-home", "docker-remove"]); + }); + + it("warns when an associated home remains after permission fallback", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: async () => { + calls.push("cleanup-project"); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(manager.drain()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Failed to remove temp home")); + } finally { + warn.mockRestore(); + } + expect(calls).toEqual([ + "cleanup-project", + "dispose-home", + "docker-remove", + "chmod", + "dispose-home", + ]); + }); + + it("disposes an associated home once after all projects sharing it are cleaned", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-one", + cleanup: async () => { + calls.push("cleanup-project-one"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-two", + cleanup: async () => { + calls.push("cleanup-project-two"); + }, + }); + manager.associateHome("/tmp/project-one", "/tmp/home"); + manager.associateHome("/tmp/project-two", "/tmp/home"); + + await manager.drain(); + + expect(calls).toEqual(["cleanup-project-one", "cleanup-project-two", "dispose-home"]); + }); + it("falls back to chmod and retries cleanup when Docker cannot remove the project", async () => { const calls: Array = []; let attempts = 0; diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 5e043a3964..51a76fbaaf 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -76,6 +76,7 @@ type RunResult = { }; const DEFAULT_EXIT_TIMEOUT_MS = 60_000; +const DEFAULT_LEGACY_STACK_CLEANUP_TIMEOUT_MS = 120_000; const OUTPUT_TAIL_LENGTH = 4_000; interface SpawnedSupabase { @@ -84,7 +85,7 @@ interface SpawnedSupabase { readonly stdout: () => string; readonly stderr: () => string; readonly kill: (signal?: NodeJS.Signals) => void; - readonly waitForOutput: (pattern: RegExp, timeoutMs?: number) => Promise; + readonly waitForOutput: (pattern: RegExp, timeoutMs?: number, startAt?: number) => Promise; readonly waitForExit: (timeoutMs?: number) => Promise; } @@ -141,6 +142,51 @@ async function makeTempProject(prefix = "supabase-project-e2e-") { }; } +/** Create an isolated CLI project without pre-allocating released ports. */ +export async function makeTempCliProject(prefix = "supabase-cli-e2e-") { + const project = await makeTempProject(prefix); + registerTempStackProject(project); + return project; +} + +export async function makeTempLegacyStackProject( + prefix = "supabase-legacy-stack-e2e-", + cleanupTimeoutMs = DEFAULT_LEGACY_STACK_CLEANUP_TIMEOUT_MS, +) { + const project = await makeTempProject(prefix); + const cleanup = async () => { + if (!existsSync(project.dir)) return; + + // `init` can fail before creating a project config. There is no stack to + // stop in that case, so remove the exact owned directory directly. + if (!existsSync(path.join(project.dir, "supabase", "config.toml"))) { + await rm(project.dir, { recursive: true, force: true }); + return; + } + + const stopped = await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: project.dir, + exitTimeoutMs: cleanupTimeoutMs, + }); + if (stopped.exitCode !== 0) { + throw new Error( + [ + `Failed to stop legacy stack in ${project.dir} (exit code ${stopped.exitCode}).`, + `stdout:\n${stopped.stdout}`, + `stderr:\n${stopped.stderr}`, + ].join("\n"), + ); + } + + await rm(project.dir, { recursive: true, force: true }); + }; + + const stackProject = { dir: project.dir, cleanup }; + registerTempStackProject(stackProject); + return stackProject; +} + export async function makeTempStackProject(prefix = "supabase-stack-e2e-") { const project = await makeTempProject(prefix); const ports = { @@ -369,8 +415,9 @@ export function spawnSupabase( proc.kill(signal); } catch {} }, - waitForOutput: async (pattern: RegExp, timeoutMs = 60_000) => { - if (pattern.test(stdout)) { + waitForOutput: async (pattern: RegExp, timeoutMs = 60_000, startAt = 0) => { + pattern.lastIndex = 0; + if (pattern.test(stdout.slice(startAt))) { return; } if (closeResult) { @@ -402,7 +449,8 @@ export function spawnSupabase( }, timeoutMs); const onStdout = (_data: Buffer) => { - if (pattern.test(stdout)) { + pattern.lastIndex = 0; + if (pattern.test(stdout.slice(startAt))) { cleanup(); resolve(); } @@ -473,3 +521,14 @@ export async function runSupabase( const result = await spawned.waitForExit(); return { ...result, exitCode: killedByUntil ? 0 : result.exitCode }; } + +export function requireCliSuccess( + result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string }, + command: string, +): void { + if (result.exitCode !== 0) { + throw new Error( + `${command} failed (exit ${result.exitCode})\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} diff --git a/apps/cli/tests/helpers/live-env.ts b/apps/cli/tests/helpers/live-env.ts index f34cd1734d..92c2519b79 100644 --- a/apps/cli/tests/helpers/live-env.ts +++ b/apps/cli/tests/helpers/live-env.ts @@ -1,125 +1,64 @@ -/** - * Environment-only helpers for the `live` Vitest project, with **no Vitest test - * APIs imported**. Vitest evaluates `globalSetup` (live-global-setup.ts) in a - * separate context before the test workers, where importing `describe`/`test` - * is not valid — so the global setup imports the env helpers from here, while - * the test-facing pieces (`describeLive`, `runSupabaseLive`, …) live in - * `live.ts` and re-export these. - * - * Environment contract (provided by the cli-e2e-ci runner): - * - `SUPABASE_ACCESS_TOKEN` — required; the platform PAT (supabox seeds a - * deterministic `sbp_…` token into its mgmt-api database). - * - `SUPABASE_PROFILE` — selects the API base URL; defaults to `supabase-local` - * (→ `http://localhost:8080`, `project_host: supabase.red`). Note the cli does - * NOT honor `SUPABASE_API_URL` (Go parity) — the profile is the override. - * - `SUPABASE_LIVE_API_URL` — base URL the readiness check probes; defaults to - * `http://localhost:8080`. - * - `SUPABASE_LIVE_PROJECT_REF` — a provisioned project; gates project-scoped - * suites (functions, branches, db, storage). - * - `NODE_EXTRA_CA_CERTS` — trusts the supabox CA for `*.supabase.red` TLS; - * inherited by the subprocess via the parent environment. - */ +/** Environment-only live-suite configuration. */ -/** Default profile for the host runner: api_url → localhost:8080, project_host → supabase.red. */ -export const LIVE_DEFAULT_PROFILE = "supabase-local"; - -/** - * Default subprocess exit timeout for live runs. `runSupabase` otherwise caps at - * 60s, which would kill a slow-but-valid supabox call before the live tests' - * own (60–120s+) timeouts fire. Generous, but under the `live` project's 300s - * cap so the per-test timeout stays the real gate. Callers may override. - */ export const LIVE_EXIT_TIMEOUT_MS = 240_000; -/** Management API base URL probed by the live readiness check. */ -export function liveApiBaseUrl(): string { - return process.env["SUPABASE_LIVE_API_URL"] ?? "http://localhost:8080"; +export function liveApiUrl(): string { + const value = process.env["SUPABASE_LIVE_API_URL"]?.trim(); + if (value === undefined || value.length === 0) { + throw new Error("SUPABASE_LIVE_API_URL is required to run the live suite"); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`SUPABASE_LIVE_API_URL must be an absolute HTTP(S) URL: ${value}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`SUPABASE_LIVE_API_URL must use http:// or https://: ${value}`); + } + return url.toString().replace(/\/+$/u, ""); } -/** - * True when the environment carries a platform access token, i.e. the live - * suite is expected to run. Used to gate `describeLive` so live tests are inert - * in the default test loop. - */ -export function isLiveConfigured(): boolean { - return Boolean(process.env["SUPABASE_ACCESS_TOKEN"]); +export function liveAccessToken(): string { + const token = process.env["SUPABASE_ACCESS_TOKEN"]?.trim(); + if (token === undefined || token.length === 0) { + throw new Error("SUPABASE_ACCESS_TOKEN is required to run the live suite"); + } + return token; } -/** - * Project ref for project-scoped live scenarios (functions, branches, db, - * storage, …). The cli-e2e-ci runner sets this once a project has been - * provisioned on the stack; absent → those suites skip. Returns `undefined` - * when unset so callers can branch; use `requireLiveProjectRef` inside a - * `describeLiveProject` block where presence is already guaranteed. - */ -export function liveProjectRef(): string | undefined { - return process.env["SUPABASE_LIVE_PROJECT_REF"]; +export function validateLiveConfig(): { readonly apiUrl: string; readonly accessToken: string } { + return { apiUrl: liveApiUrl(), accessToken: liveAccessToken() }; } -/** - * The live project ref, or a thrown error if unset. Safe to call inside a - * `describeLiveProject` block (the gate guarantees it is present) and gives a - * typed `string` without a non-null assertion. - */ -export function requireLiveProjectRef(): string { - const ref = liveProjectRef(); - if (!ref) { - throw new Error( - "SUPABASE_LIVE_PROJECT_REF must be set for project-scoped live tests " + - "(the cli-e2e-ci runner sets it after provisioning a project).", - ); - } - return ref; +export function keepLiveProject(): boolean { + return process.env["SUPABASE_LIVE_KEEP_PROJECT"] === "1"; } -/** - * Whether the live project's *data-plane* — its own Postgres instance — is up - * and healthy. This is a stronger gate than `liveProjectRef()`: cli-e2e-ci - * currently builds the stack WITHOUT `supabase-postgres-17` (CLI-1825), so a - * provisioned project's *record* exists — Management-API reads (orgs / projects - * / functions / branches list) work — but the instance never reaches - * `ACTIVE_HEALTHY` and its database is unreachable. Commands that talk to the - * project Postgres (migration, db, storage) gate on this and SKIP until the full - * stack lands, then activate automatically. - * - * Probes `GET /v1/projects` (already proven reachable by `projects list`) and - * matches the live ref. Any failure or missing prerequisite returns `false` — - * "not ready" is the safe default, so a probe error skips rather than fails the - * suite. - */ -export async function liveProjectDataPlaneReady(): Promise { - const token = process.env["SUPABASE_ACCESS_TOKEN"]; - const ref = liveProjectRef(); - if (token === undefined || token.length === 0 || ref === undefined) { - return false; - } +export function liveProjectName(): string { + return process.env["SUPABASE_LIVE_PROJECT_NAME"]?.trim() || "supabase-cli-live"; +} - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - try { - const response = await fetch(`${liveApiBaseUrl()}/v1/projects`, { - headers: { Authorization: `Bearer ${token}` }, - signal: controller.signal, - }); - if (!response.ok) { - return false; - } - const projects: unknown = await response.json(); - if (!Array.isArray(projects)) { - return false; - } - return projects.some( - (candidate) => - candidate !== null && - typeof candidate === "object" && - "ref" in candidate && - candidate.ref === ref && - "status" in candidate && - candidate.status === "ACTIVE_HEALTHY", +export function liveRegion(): string { + return process.env["SUPABASE_LIVE_REGION"]?.trim() || "us-east-1"; +} + +export function liveOrgId(): string | undefined { + const value = process.env["SUPABASE_LIVE_ORG_ID"]?.trim(); + return value === undefined || value.length === 0 ? undefined : value; +} + +/** Resolve `.` from a database host such as `db..supabase.co`. */ +export function deriveLiveProjectHost(databaseHost: string, projectRef: string): string { + const prefix = `db.${projectRef}.`; + if (!databaseHost.startsWith(prefix)) { + throw new Error( + `Cannot derive project host for ${projectRef} from database host ${databaseHost}; expected a ${prefix} name`, ); - } catch { - return false; - } finally { - clearTimeout(timeout); } + const host = databaseHost.slice(prefix.length); + if (host.length === 0 || host.includes("/")) { + throw new Error(`Cannot derive a valid project host from database host ${databaseHost}`); + } + return host; } diff --git a/apps/cli/tests/helpers/live-env.unit.test.ts b/apps/cli/tests/helpers/live-env.unit.test.ts new file mode 100644 index 0000000000..b5704a6a9c --- /dev/null +++ b/apps/cli/tests/helpers/live-env.unit.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { deriveLiveProjectHost, liveApiUrl, validateLiveConfig } from "./live-env.ts"; + +const originalApiUrl = process.env["SUPABASE_LIVE_API_URL"]; +const originalToken = process.env["SUPABASE_ACCESS_TOKEN"]; + +afterEach(() => { + if (originalApiUrl === undefined) delete process.env["SUPABASE_LIVE_API_URL"]; + else process.env["SUPABASE_LIVE_API_URL"] = originalApiUrl; + if (originalToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; + else process.env["SUPABASE_ACCESS_TOKEN"] = originalToken; +}); + +describe("live environment", () => { + it("requires both the API URL and access token", () => { + delete process.env["SUPABASE_LIVE_API_URL"]; + delete process.env["SUPABASE_ACCESS_TOKEN"]; + expect(() => validateLiveConfig()).toThrow("SUPABASE_LIVE_API_URL is required"); + + process.env["SUPABASE_LIVE_API_URL"] = "http://localhost:8080"; + expect(() => validateLiveConfig()).toThrow("SUPABASE_ACCESS_TOKEN is required"); + }); + + it("normalizes and validates HTTP API URLs", () => { + process.env["SUPABASE_LIVE_API_URL"] = "http://localhost:8080///"; + process.env["SUPABASE_ACCESS_TOKEN"] = " token "; + expect(validateLiveConfig()).toEqual({ + apiUrl: "http://localhost:8080", + accessToken: "token", + }); + process.env["SUPABASE_LIVE_API_URL"] = "not-a-url"; + expect(() => liveApiUrl()).toThrow("absolute HTTP(S) URL"); + }); + + it("derives the project host from the typed database host", () => { + expect( + deriveLiveProjectHost("db.abcdefghijklmnopqrst.supabase.co", "abcdefghijklmnopqrst"), + ).toBe("supabase.co"); + expect(() => deriveLiveProjectHost("postgres.supabase.co", "abcdefghijklmnopqrst")).toThrow( + "Cannot derive project host", + ); + }); +}); diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts new file mode 100644 index 0000000000..7beededf64 --- /dev/null +++ b/apps/cli/tests/helpers/live-project.ts @@ -0,0 +1,584 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { makeApiClient, type OperationOutput } from "@supabase/api/effect"; +import { Cause, Data, Effect, Exit, Schedule } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { + deriveLiveProjectHost, + keepLiveProject, + liveApiUrl, + liveOrgId, + liveProjectName, + liveRegion, +} from "./live-env.ts"; + +const PROJECT_REF_RE = /^[a-z]{20}$/u; +const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); +const PROFILE_NAME = "supabase-cli-live"; +const POLL_INTERVAL = "5 seconds"; +const POLL_TIMEOUT = "5 minutes"; + +type Project = OperationOutput<"v1GetProject">; +type Organization = OperationOutput<"v1ListAllOrganizations">[number]; +type ApiKey = OperationOutput<"v1GetProjectApiKeys">[number]; +export type PoolerConfig = OperationOutput<"v1GetPoolerConfig">[number]; +type LiveApi = Effect.Success>; +type Region = + | "us-east-1" + | "us-east-2" + | "us-west-1" + | "us-west-2" + | "ap-east-1" + | "ap-southeast-1" + | "ap-northeast-1" + | "ap-northeast-2" + | "ap-southeast-2" + | "eu-west-1" + | "eu-west-2" + | "eu-west-3" + | "eu-north-1" + | "eu-central-1" + | "eu-central-2" + | "ca-central-1" + | "ap-south-1" + | "sa-east-1"; + +const REGIONS: ReadonlyArray = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1", +]; + +class LiveTransientPoll extends Data.TaggedError("LiveTransientPoll")<{ + readonly phase: string; + readonly cause?: unknown; +}> {} + +class LiveTerminalPoll extends Data.TaggedError("LiveTerminalPoll")<{ + readonly phase: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +class LivePollTimeout extends Data.TaggedError("LivePollTimeout")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} timed out`; + } +} + +class LiveStorageError extends Data.TaggedError("LiveStorageError")<{ + readonly message: string; + readonly retryable: boolean; + readonly cause?: unknown; +}> {} + +function apiError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export function supportedRegion(value: string): Effect.Effect { + const region = REGIONS.find((candidate) => candidate === value); + return region === undefined + ? Effect.fail(new Error(`Unsupported SUPABASE_LIVE_REGION ${JSON.stringify(value)}`)) + : Effect.succeed(region); +} + +/** HTTP statuses that can occur while a newly-created project propagates. */ +export function isTransientStorageStatus(status: number): boolean { + return status === 404 || status === 408 || status === 425 || status === 429 || status >= 500; +} + +/** Retry only transport failures and statuses plausibly caused by propagation. */ +export function isTransientLiveError(error: unknown): boolean { + if (!HttpClientError.isHttpClientError(error)) return false; + if (error.reason._tag === "TransportError") return true; + return ( + error.reason._tag === "StatusCodeError" && + isTransientStorageStatus(error.reason.response.status) + ); +} + +export function selectPrimaryPoolerConfig( + configs: ReadonlyArray, +): PoolerConfig | undefined { + return configs.find((config) => config.database_type === "PRIMARY"); +} + +export function resolvePoolerDatabaseUrl( + connectionString: string, + poolMode: PoolerConfig["pool_mode"], + password: string, +): string { + const url = new URL(connectionString); + url.password = password; + if (poolMode !== "session" && url.port === "6543") url.port = "5432"; + if (!url.searchParams.has("connect_timeout")) url.searchParams.set("connect_timeout", "30"); + return url.toString(); +} + +function classifyPollError(phase: string, cause: unknown): LiveTransientPoll | LiveTerminalPoll { + return isTransientLiveError(cause) + ? new LiveTransientPoll({ phase, cause }) + : new LiveTerminalPoll({ + phase, + message: `${phase} failed: ${apiError(cause).message}`, + cause, + }); +} + +/** Retry a transient management operation using Effect's schedule and deadline semantics. */ +export function retryLiveEffect( + phase: string, + effect: Effect.Effect, + options: { + readonly interval?: import("effect").Duration.Input; + readonly timeout?: import("effect").Duration.Input; + readonly shouldRetry?: (error: E) => boolean; + } = {}, +): Effect.Effect { + const retrying = Effect.retry(effect, { + schedule: Schedule.spaced(options.interval ?? POLL_INTERVAL), + ...(options.shouldRetry === undefined ? {} : { while: options.shouldRetry }), + }); + return Effect.timeoutOrElse(retrying, { + duration: options.timeout ?? POLL_TIMEOUT, + orElse: () => Effect.fail(new LivePollTimeout({ phase })), + }); +} + +/** Build one diagnostic while retaining every target and cleanup failure. */ +export function cleanupErrors(primary: unknown, cleanup: ReadonlyArray): AggregateError { + const errors = [primary, ...cleanup].map(apiError); + return new AggregateError(errors, "Live e2e lifecycle failed"); +} + +function timeoutLiveRequest( + phase: string, + effect: Effect.Effect, +): Effect.Effect { + return Effect.timeoutOrElse(effect, { + duration: POLL_TIMEOUT, + orElse: () => Effect.fail(new Error(`${phase} timed out`)), + }); +} + +function uniqueProjectName(): string { + const runId = process.env["GITHUB_RUN_ID"] ?? process.env["CI_JOB_ID"] ?? String(Date.now()); + return `${liveProjectName()}-${runId}-${randomUUID().slice(0, 8)}`; +} + +function databasePassword(): string { + return `supabase-cli-live-${randomBytes(12).toString("hex")}`; +} + +function resolveOrganization(api: LiveApi): Effect.Effect { + return timeoutLiveRequest("organization lookup", api.v1.listAllOrganizations()).pipe( + Effect.mapError(apiError), + Effect.flatMap((organizations) => { + const requested = liveOrgId(); + const organization = + requested === undefined + ? organizations[0] + : organizations.find( + (candidate) => candidate.id === requested || candidate.slug === requested, + ); + return organization === undefined + ? Effect.fail( + new Error( + requested === undefined + ? "No organizations found; cannot create the live project" + : `Organization ${requested} was not found; cannot create the live project`, + ), + ) + : Effect.succeed(organization); + }), + ); +} + +function createProject( + api: LiveApi, + name: string, + password: string, +): Effect.Effect { + return supportedRegion(liveRegion()).pipe( + Effect.flatMap((region) => + resolveOrganization(api).pipe( + Effect.flatMap((organization) => + timeoutLiveRequest( + "project creation", + api.v1.createAProject({ + name, + db_pass: password, + organization_slug: organization.slug, + region, + }), + ).pipe(Effect.mapError(apiError)), + ), + ), + ), + Effect.flatMap((project) => + PROJECT_REF_RE.test(project.ref) + ? Effect.succeed(project.ref) + : Effect.fail(new Error(`Unexpected project ref from project creation: ${project.ref}`)), + ), + ); +} + +function deleteProject(api: LiveApi, ref: string): Effect.Effect { + return timeoutLiveRequest("project deletion", api.v1.deleteAProject({ ref })).pipe( + Effect.mapError(apiError), + Effect.asVoid, + ); +} + +function projectReadiness( + api: LiveApi, + ref: string, +): Effect.Effect { + return api.v1.getProject({ ref }).pipe( + Effect.mapError((cause) => classifyPollError("project readiness", cause)), + Effect.flatMap( + (project): Effect.Effect => { + if (project.status === "ACTIVE_HEALTHY") return Effect.succeed(project); + if (TERMINAL_BAD_STATUSES.has(project.status)) { + return Effect.fail( + new LiveTerminalPoll({ + phase: "project readiness", + message: `Project ${ref} entered terminal status ${project.status}`, + }), + ); + } + return Effect.fail( + new LiveTransientPoll({ + phase: "project readiness", + cause: `status=${project.status}`, + }), + ); + }, + ), + ); +} + +function waitForProject(api: LiveApi, ref: string): Effect.Effect { + return retryLiveEffect("project readiness", projectReadiness(api, ref), { + shouldRetry: (error) => error instanceof LiveTransientPoll, + }).pipe( + Effect.mapError((error) => { + if (error instanceof LiveTerminalPoll) return new Error(error.message); + if (error instanceof LivePollTimeout) return new Error(error.message); + return apiError(error); + }), + ); +} + +function keysReadiness( + api: LiveApi, + ref: string, +): Effect.Effect< + { anonKey: string; serviceRoleKey: string }, + LiveTransientPoll | LiveTerminalPoll, + never +> { + return api.v1.getProjectApiKeys({ ref, reveal: true }).pipe( + Effect.mapError((cause) => classifyPollError("project API keys", cause)), + Effect.flatMap((keys) => { + const keyValue = (key: ApiKey): string | undefined => key.api_key ?? undefined; + const anonKey = keys.find((key) => key.name === "anon"); + const serviceRoleKey = + keys.find((key) => key.name === "service_role") ?? + keys.find((key) => key.api_key?.startsWith("sb_secret_")); + const anon = anonKey === undefined ? undefined : keyValue(anonKey); + const service = serviceRoleKey === undefined ? undefined : keyValue(serviceRoleKey); + return anon === undefined || service === undefined + ? Effect.fail( + new LiveTransientPoll({ phase: "project API keys", cause: "keys incomplete" }), + ) + : Effect.succeed({ anonKey: anon, serviceRoleKey: service }); + }), + ); +} + +function resolveKeys( + api: LiveApi, + ref: string, +): Effect.Effect<{ anonKey: string; serviceRoleKey: string }, Error, never> { + return retryLiveEffect("project API keys", keysReadiness(api, ref), { + shouldRetry: (error) => error instanceof LiveTransientPoll, + }).pipe( + Effect.mapError((error) => + error instanceof LivePollTimeout + ? new Error(`Project ${ref} did not return API keys within ${POLL_TIMEOUT}`) + : error instanceof LiveTerminalPoll + ? new Error(error.message) + : apiError(error), + ), + ); +} + +function dbReadiness( + api: LiveApi, + ref: string, + password: string, +): Effect.Effect { + return api.v1.getPoolerConfig({ ref }).pipe( + Effect.mapError((cause): LiveTransientPoll | LiveTerminalPoll => + classifyPollError("pooler configuration", cause), + ), + Effect.flatMap( + (configs): Effect.Effect => { + const primary = selectPrimaryPoolerConfig(configs); + if (primary === undefined || primary.connection_string.trim().length === 0) { + return Effect.fail( + new LiveTransientPoll({ + phase: "pooler configuration", + cause: + primary === undefined + ? "primary pooler config missing" + : "connection string missing", + }), + ); + } + try { + return Effect.succeed( + resolvePoolerDatabaseUrl(primary.connection_string, primary.pool_mode, password), + ); + } catch (cause) { + return Effect.fail( + new LiveTerminalPoll({ + phase: "pooler configuration", + message: `pooler configuration returned an invalid connection string: ${apiError(cause).message}`, + cause, + }), + ); + } + }, + ), + ); +} + +function resolveDbUrl( + api: LiveApi, + ref: string, + password: string, +): Effect.Effect { + return retryLiveEffect("pooler configuration", dbReadiness(api, ref, password), { + shouldRetry: (error) => error instanceof LiveTransientPoll, + }).pipe( + Effect.mapError((error) => + error instanceof LivePollTimeout + ? new Error( + `Project ${ref} did not return a pooler connection string within ${POLL_TIMEOUT}`, + ) + : error instanceof LiveTerminalPoll + ? new Error(error.message) + : apiError(error), + ), + ); +} + +function createStorageBucket( + ref: string, + host: string, + serviceRoleKey: string, + bucket: string, +): Effect.Effect { + const attempt = Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(`https://${ref}.${host}/storage/v1/bucket`, { + method: "POST", + headers: { Authorization: `Bearer ${serviceRoleKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ id: bucket, name: bucket, public: false }), + signal, + }); + if (!response.ok && response.status !== 409) { + throw new LiveStorageError({ + message: `Failed to create storage bucket ${bucket}: ${response.status} ${await response.text()}`, + retryable: isTransientStorageStatus(response.status), + }); + } + }, + catch: (cause) => + cause instanceof LiveStorageError + ? cause + : new LiveStorageError({ + message: `Failed to create storage bucket ${bucket}: ${apiError(cause).message}`, + retryable: true, + cause, + }), + }); + return retryLiveEffect("storage bucket", attempt, { + shouldRetry: (error) => error instanceof LiveStorageError && error.retryable, + }).pipe( + Effect.mapError((error) => + error instanceof LivePollTimeout + ? new Error(`storage bucket ${bucket} creation timed out`) + : error instanceof LiveStorageError + ? new Error(error.message) + : apiError(error), + ), + ); +} + +function writeProfile( + projectRef: string, + projectHost: string, + dbUrl: string, +): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const directory = await mkdtemp(path.join(tmpdir(), "supabase-live-profile-")); + const profilePath = path.join(directory, "profile.yaml"); + try { + const poolerHost = new URL(dbUrl).hostname; + await writeFile( + profilePath, + [ + `name: ${PROFILE_NAME}`, + `api_url: ${JSON.stringify(liveApiUrl())}`, + `dashboard_url: ${JSON.stringify(liveApiUrl())}`, + `project_host: ${projectHost}`, + `pooler_host: ${poolerHost}`, + `# provisioned project: ${projectRef}`, + "", + ].join("\n"), + ); + return profilePath; + } catch (cause) { + try { + await rm(directory, { recursive: true, force: true }); + } catch (cleanup) { + throw cleanupErrors(cause, [cleanup]); + } + throw cause; + } + }, + catch: apiError, + }); +} + +function cleanupDirectory(profilePath: string): Effect.Effect { + return Effect.tryPromise({ + try: () => rm(path.dirname(profilePath), { recursive: true, force: true }), + catch: apiError, + }); +} + +function cleanupRemote( + api: LiveApi, + environment: LiveProjectEnvironment, +): Effect.Effect { + return keepLiveProject() + ? Effect.sync(() => { + console.log(`SUPABASE_LIVE_KEEP_PROJECT=1 — leaving ${environment.project.ref} alive`); + }) + : deleteProject(api, environment.project.ref); +} + +function cleanupCreatedProject(api: LiveApi, ref: string): Effect.Effect { + return keepLiveProject() + ? Effect.sync(() => { + console.log( + `SUPABASE_LIVE_KEEP_PROJECT=1 — leaving ${ref} alive after provisioning failure`, + ); + }) + : deleteProject(api, ref); +} + +function combineCleanupExits( + exits: ReadonlyArray>, +): Effect.Effect { + const errors = exits.flatMap((exit) => (Exit.isFailure(exit) ? [Cause.squash(exit.cause)] : [])); + return errors.length === 0 + ? Effect.void + : Effect.fail(new AggregateError(errors, "Live cleanup failed")); +} + +export interface LiveProjectEnvironment { + readonly project: { + readonly ref: string; + readonly dbUrl: string; + readonly dbPassword: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly functionsUrl: string; + readonly storageBucket: string; + }; + readonly profilePath: string; +} + +/** Provision one project; the caller owns the outer Effect runtime boundary. */ +export function provisionLiveEnvironment( + api: LiveApi, +): Effect.Effect { + return Effect.gen(function* () { + const password = databasePassword(); + const ref = yield* createProject(api, uniqueProjectName(), password); + const setup = Effect.gen(function* () { + const project = yield* waitForProject(api, ref); + const projectHost = deriveLiveProjectHost(project.database.host, ref); + const keys = yield* resolveKeys(api, ref); + const dbUrl = yield* resolveDbUrl(api, ref, password); + const storageBucket = "supabase-cli-live-bucket"; + yield* createStorageBucket(ref, projectHost, keys.serviceRoleKey, storageBucket); + const profilePath = yield* writeProfile(ref, projectHost, dbUrl); + return { + project: { + ref, + dbUrl, + dbPassword: password, + anonKey: keys.anonKey, + serviceRoleKey: keys.serviceRoleKey, + functionsUrl: `https://${ref}.${projectHost}/functions/v1`, + storageBucket, + }, + profilePath, + } satisfies LiveProjectEnvironment; + }); + const setupExit = yield* Effect.exit(setup); + if (Exit.isSuccess(setupExit)) return setupExit.value; + + const cleanupExit = yield* Effect.exit(cleanupCreatedProject(api, ref)); + if (Exit.isSuccess(cleanupExit)) return yield* Effect.failCause(setupExit.cause); + return yield* Effect.fail( + cleanupErrors(Cause.squash(setupExit.cause), [Cause.squash(cleanupExit.cause)]), + ); + }); +} + +/** Delete the exact owned project and always remove its temporary profile. */ +export function cleanupLiveEnvironment( + api: LiveApi, + environment: LiveProjectEnvironment, +): Effect.Effect { + return Effect.gen(function* () { + const [profileExit, projectExit] = yield* Effect.all( + [ + Effect.exit(cleanupDirectory(environment.profilePath)), + Effect.exit(cleanupRemote(api, environment)), + ], + { concurrency: "unbounded" }, + ); + yield* combineCleanupExits([profileExit, projectExit]); + }); +} diff --git a/apps/cli/tests/helpers/live-project.unit.test.ts b/apps/cli/tests/helpers/live-project.unit.test.ts new file mode 100644 index 0000000000..4da9bd2bc0 --- /dev/null +++ b/apps/cli/tests/helpers/live-project.unit.test.ts @@ -0,0 +1,185 @@ +import { Effect } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { describe, expect, it } from "vitest"; + +import { + cleanupErrors, + isTransientLiveError, + isTransientStorageStatus, + resolvePoolerDatabaseUrl, + retryLiveEffect, + selectPrimaryPoolerConfig, + supportedRegion, + type PoolerConfig, +} from "./live-project.ts"; + +function statusError(status: number): HttpClientError.HttpClientError { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/test"); + const response = HttpClientResponse.fromWeb(request, new Response(null, { status })); + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); +} + +function poolerConfig(overrides: Partial = {}): PoolerConfig { + return { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: false, + db_user: "postgres", + db_host: "pooler.example.com", + db_port: 6543, + db_name: "postgres", + connection_string: "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.com:6543/postgres", + connectionString: "", + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + ...overrides, + }; +} + +describe("live project lifecycle", () => { + it("fails invalid regions before provisioning", async () => { + await expect( + Effect.runPromise(Effect.flip(supportedRegion("not-a-region"))), + ).resolves.toMatchObject({ + message: expect.stringContaining("Unsupported SUPABASE_LIVE_REGION"), + }); + await expect(Effect.runPromise(supportedRegion("us-east-1"))).resolves.toBe("us-east-1"); + }); + + it("retries transient failures until the management operation succeeds", async () => { + let attempts = 0; + const result = await Effect.runPromise( + retryLiveEffect( + "project readiness", + Effect.suspend(() => + Effect.sync(() => { + attempts += 1; + return attempts < 3 + ? Effect.fail(new Error("temporarily unavailable")) + : Effect.succeed("ACTIVE_HEALTHY"); + }).pipe(Effect.flatten), + ), + { interval: "1 millis", timeout: "100 millis" }, + ), + ); + + expect(result).toBe("ACTIVE_HEALTHY"); + expect(attempts).toBe(3); + }); + + it("fails a poll when its wall-clock deadline expires", async () => { + const result = Effect.runPromise( + retryLiveEffect("project keys", Effect.never, { + interval: "1 millis", + timeout: "10 millis", + }), + ); + + await expect(result).rejects.toThrow("project keys timed out"); + }); + + it("preserves both target and cleanup failures", () => { + const error = cleanupErrors(new Error("provision failed"), [ + new Error("profile cleanup failed"), + new Error("project deletion failed"), + ]); + + expect(error).toBeInstanceOf(AggregateError); + expect(error.errors).toHaveLength(3); + expect(error.errors.map((entry) => String(entry))).toEqual([ + "Error: provision failed", + "Error: profile cleanup failed", + "Error: project deletion failed", + ]); + }); + + it("retries transient API statuses but fails authorization errors immediately", async () => { + expect(isTransientLiveError(statusError(404))).toBe(true); + expect(isTransientLiveError(statusError(503))).toBe(true); + expect(isTransientLiveError(statusError(401))).toBe(false); + expect(isTransientLiveError(statusError(403))).toBe(false); + + let attempts = 0; + const transient = statusError(503); + const result = await Effect.runPromise( + retryLiveEffect( + "storage bucket", + Effect.suspend(() => { + attempts += 1; + return attempts < 3 ? Effect.fail(transient) : Effect.succeed("created"); + }), + { interval: "1 millis", timeout: "100 millis", shouldRetry: isTransientLiveError }, + ), + ); + expect(result).toBe("created"); + expect(attempts).toBe(3); + + attempts = 0; + await expect( + Effect.runPromise( + retryLiveEffect( + "project readiness", + Effect.suspend(() => { + attempts += 1; + return Effect.fail(statusError(403)); + }), + { + interval: "1 millis", + timeout: "100 millis", + shouldRetry: isTransientLiveError, + }, + ), + ), + ).rejects.toThrow(); + expect(attempts).toBe(1); + }); + + it("classifies storage responses for retry without retrying terminal client errors", () => { + expect(isTransientStorageStatus(408)).toBe(true); + expect(isTransientStorageStatus(429)).toBe(true); + expect(isTransientStorageStatus(500)).toBe(true); + expect(isTransientStorageStatus(401)).toBe(false); + expect(isTransientStorageStatus(403)).toBe(false); + expect(isTransientStorageStatus(422)).toBe(false); + }); + + it("selects the primary pooler config", () => { + const replica = poolerConfig({ identifier: "replica", database_type: "READ_REPLICA" }); + const primary = poolerConfig(); + + expect(selectPrimaryPoolerConfig([replica, primary])).toBe(primary); + }); + + it("translates transaction pooler port and encodes the password", () => { + const resolved = new URL( + resolvePoolerDatabaseUrl( + "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.com:6543/postgres", + "transaction", + "p@ss word", + ), + ); + + expect(resolved.hostname).toBe("pooler.example.com"); + expect(resolved.port).toBe("5432"); + expect(decodeURIComponent(resolved.password)).toBe("p@ss word"); + expect(resolved.searchParams.get("connect_timeout")).toBe("30"); + }); + + it("preserves the API port and timeout for session pooler mode", () => { + const resolved = new URL( + resolvePoolerDatabaseUrl( + "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.com:6543/postgres?connect_timeout=7", + "session", + "secret", + ), + ); + + expect(resolved.port).toBe("6543"); + expect(resolved.searchParams.get("connect_timeout")).toBe("7"); + }); +}); diff --git a/apps/cli/tests/helpers/live-provided-context.ts b/apps/cli/tests/helpers/live-provided-context.ts new file mode 100644 index 0000000000..deb9a63adf --- /dev/null +++ b/apps/cli/tests/helpers/live-provided-context.ts @@ -0,0 +1,18 @@ +// Vitest evaluates global setup separately from test modules. Keep this module +// side-effect-free so global setup can provide the shared live environment. +export {}; + +declare module "vitest" { + export interface ProvidedContext { + liveProject: { + readonly ref: string; + readonly dbUrl: string; + readonly dbPassword: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly functionsUrl: string; + readonly storageBucket: string; + }; + liveProfilePath: string; + } +} diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 8123bc96dd..a5d3e5e848 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -1,100 +1,160 @@ -import { execSync } from "node:child_process"; -import { describe } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; -import { runSupabase } from "./cli.ts"; -import { - isLiveConfigured, - LIVE_DEFAULT_PROFILE, - LIVE_EXIT_TIMEOUT_MS, - liveProjectDataPlaneReady, - liveProjectRef, -} from "./live-env.ts"; +import { inject, test as vitestTest } from "vitest"; -/** - * Test-facing helpers for the `live` Vitest project (`*.live.test.ts`): - * black-box CLI subprocess tests that run against a *real* Supabase platform — - * in CI a local supabox stack (see the `supabase/cli-e2e-ci` harness). - * - * This module imports Vitest test APIs (`describe`), so it must NOT be imported - * from `globalSetup` (Vitest evaluates that in a different context). The - * env-only helpers live in `./live-env.ts`; `globalSetup` imports from there. - * They are re-exported below so test files have a single import site. - */ +import { makeTempHome, runSupabase } from "./cli.ts"; +import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; +import type { LiveProjectEnvironment } from "./live-project.ts"; -// Re-export the env-only helpers so `*.live.test.ts` files import everything -// from `helpers/live.ts`. -export { - isLiveConfigured, - LIVE_DEFAULT_PROFILE, - LIVE_EXIT_TIMEOUT_MS, - liveApiBaseUrl, - liveProjectDataPlaneReady, - liveProjectRef, - requireLiveProjectRef, -} from "./live-env.ts"; +export type LiveProject = LiveProjectEnvironment["project"]; +type RunOptions = NonNullable[1]>; +type RunResult = Awaited>; -/** - * `describe` that runs only when the live environment is configured. Use this - * for every live suite so the file is inert (skipped, not failed) outside the - * cli-e2e-ci runner. - */ -export const describeLive = describe.skipIf(!isLiveConfigured()); +export interface LiveWorkspace { + readonly path: string; +} -function hasDockerDaemon(): boolean { - try { - execSync("docker info", { stdio: "ignore" }); - return true; - } catch { - return false; - } +export interface InvokeResult { + readonly status: number; + readonly body: unknown; + readonly text: string; } -/** - * `describe` for local-stack live tests that additionally require a reachable - * Docker daemon. Composes the configured-live gate (`isLiveConfigured`) with a - * `docker info` probe so these suites stay inert (skipped, not failed) outside - * the cli-e2e-ci runner — a machine that merely exposes Docker must never - * launch a real stack just by collecting the live Vitest project. The - * synchronous read-only probe runs once when this helper module is collected, - * and only when the live environment is configured. - */ -export const describeDockerLive = describe.skipIf(!isLiveConfigured() || !hasDockerDaemon()); +export interface LiveFixtures { + readonly project: LiveProject; + readonly workspace: LiveWorkspace; + readonly home: ReturnType; + readonly cli: (args: string[], options?: RunOptions) => Promise; + readonly invoke: ( + slug: string, + options?: { readonly anonKey?: string; readonly payload?: unknown }, + ) => Promise; +} + +const base = vitestTest.extend({ + // eslint-disable-next-line no-empty-pattern + project: async ({}, use) => use(inject("liveProject")), + + home: async ({ task: _task }, use) => { + const home = makeTempHome(); + try { + await use(home); + } finally { + home[Symbol.dispose](); + } + }, -/** - * `describe` for project-scoped live suites: runs only when the live env is - * configured AND a project ref is available. On a control-plane-only stack - * (e.g. local macOS where project instances can't be built) these skip rather - * than fail. See `requireLiveProjectRef`. - */ -export const describeLiveProject = describe.skipIf(!isLiveConfigured() || !liveProjectRef()); + workspace: async ({ task, home }, use) => { + const suffix = task.name.replace(/[^a-z0-9-]+/giu, "-").slice(0, 40); + const directory = mkdtempSync(path.join(tmpdir(), `supabase-live-${suffix || "test"}-`)); + try { + const initialized = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: directory, + home: home.dir, + env: { SUPABASE_PROFILE: inject("liveProfilePath") }, + }); + if (initialized.exitCode !== 0) { + throw new Error( + `supabase init failed (exit ${initialized.exitCode})\n${initialized.stderr || initialized.stdout}`, + ); + } + await use({ path: directory }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, -/** - * `describe` for data-plane live suites (migration / db / storage): runs only - * when the live env is configured AND the project's own Postgres instance is - * `ACTIVE_HEALTHY`. On a control-plane-only stack — including the current - * cli-e2e-ci CI, which omits `supabase-postgres-17` (CLI-1825) — the project DB - * is unreachable, so these SKIP rather than fail. They activate automatically - * once the full data-plane is provisioned. The readiness probe runs once at - * collection time (top-level await); see `liveProjectDataPlaneReady`. - */ -export const describeLiveDataPlane = describe.skipIf(!(await liveProjectDataPlaneReady())); + cli: async ({ workspace, home }, use) => { + await use((args, options) => + runSupabase(args, { + entrypoint: "legacy", + ...options, + cwd: options?.cwd ?? workspace.path, + home: home.dir, + exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, + env: { + SUPABASE_PROFILE: inject("liveProfilePath"), + ...options?.env, + }, + }), + ); + }, -/** - * Spawn the built CLI against the live platform, injecting the profile so the - * Management API base resolves to the stack. Defaults to the `legacy` shell, - * which hosts the platform commands (orgs, projects, branches, functions, …). - */ -export function runSupabaseLive( - args: string[], - options?: Parameters[1], -): ReturnType { - return runSupabase(args, { - entrypoint: "legacy", - ...options, - exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, - env: { - SUPABASE_PROFILE: process.env["SUPABASE_PROFILE"] ?? LIVE_DEFAULT_PROFILE, - ...options?.env, - }, - }); + invoke: async ({ project }, use) => { + await use(async (slug, options) => { + const key = options?.anonKey ?? project.anonKey; + const headers: Record = { "Content-Type": "application/json" }; + if (key.length > 0) { + headers["Authorization"] = `Bearer ${key}`; + headers["apikey"] = key; + } + const response = await fetch(`${project.functionsUrl}/${slug}`, { + method: "POST", + headers, + body: JSON.stringify(options?.payload ?? {}), + }); + const text = await response.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: response.status, body, text }; + }); + }, +}); + +/** The sole live fixture. The live global setup owns the shared project. */ +export const test = base; + +export function requireLiveSuccess( + result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string }, + command: string, +): void { + if (result.exitCode !== 0) { + throw new Error( + `${command} failed (exit ${result.exitCode})\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} + +/** Rethrow a target failure without discarding failures from exact cleanup. */ +export function throwWithCleanup( + primary: unknown | undefined, + cleanup: ReadonlyArray, +): void { + if (primary !== undefined) { + if (cleanup.length > 0) { + throw new AggregateError([primary, ...cleanup], "Live e2e target and cleanup failed"); + } + throw primary; + } + if (cleanup.length === 1) throw cleanup[0]; + if (cleanup.length > 1) throw new AggregateError(cleanup, "Live e2e cleanup failed"); +} + +export function expectFunctionOk( + result: InvokeResult, + slug: string, + extra?: Record, +): void { + if (result.status !== 200) { + throw new Error( + `Expected function ${slug} to return 200, got ${result.status}: ${result.text}`, + ); + } + if (typeof result.body !== "object" || result.body === null) { + throw new Error(`Expected function ${slug} to return JSON: ${result.text}`); + } + const body = result.body as Record; + if (body.case !== slug || body.ok !== true) { + throw new Error(`Unexpected response from ${slug}: ${result.text}`); + } + for (const [key, value] of Object.entries(extra ?? {})) { + if (body[key] !== value) throw new Error(`Unexpected ${key} from ${slug}: ${result.text}`); + } } diff --git a/apps/cli/tests/helpers/live.unit.test.ts b/apps/cli/tests/helpers/live.unit.test.ts new file mode 100644 index 0000000000..99ac0b4d6b --- /dev/null +++ b/apps/cli/tests/helpers/live.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { throwWithCleanup } from "./live.ts"; + +describe("throwWithCleanup", () => { + it("rethrows the primary failure when cleanup succeeds", () => { + const primary = new Error("target failed"); + + expect(() => throwWithCleanup(primary, [])).toThrow(primary); + }); + + it("throws the cleanup failure when the target succeeds", () => { + const cleanup = new Error("cleanup failed"); + + expect(() => throwWithCleanup(undefined, [cleanup])).toThrow(cleanup); + }); + + it("preserves the primary and every cleanup failure", () => { + const primary = new Error("target failed"); + const cleanup = [new Error("first cleanup failed"), new Error("second cleanup failed")]; + let thrown: unknown; + + try { + throwWithCleanup(primary, cleanup); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + if (!(thrown instanceof AggregateError)) return; + expect(thrown.errors).toEqual([primary, ...cleanup]); + }); +}); diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index 7f69b7e9d4..8d571f89f7 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -139,8 +139,12 @@ function readDocumentPid(documentFile: string): number | undefined { } } -function cleanupErrorDetail(projectDir: string, error: unknown): string { - return `Failed to remove temp stack project ${projectDir}: ${ +function cleanupErrorDetail( + pathname: string, + error: unknown, + resource = "temp stack project", +): string { + return `Failed to remove ${resource} ${pathname}: ${ error instanceof Error ? error.message : String(error) }`; } @@ -222,38 +226,49 @@ async function removeProjectWithDocker(projectDir: string): Promise { return removed; } -async function cleanupProject( - project: StackProject, +async function cleanupOwnedPath( + pathname: string, + cleanup: () => void | Promise, environment: Pick< CleanupEnvironment, "removeProjectWithDocker" | "repairProjectPermissions" | "describeProjectPermissions" >, ): Promise { try { - await project.cleanup(); + await cleanup(); } catch (error) { if (!isPermissionError(error)) { throw error; } - const removedByDocker = await environment.removeProjectWithDocker(project.dir); + const removedByDocker = await environment.removeProjectWithDocker(pathname); if (removedByDocker) { return; } - environment.repairProjectPermissions(project.dir); + environment.repairProjectPermissions(pathname); try { - await project.cleanup(); + await cleanup(); } catch (retryError) { throw new Error( `${retryError instanceof Error ? retryError.message : String(retryError)}\n${environment.describeProjectPermissions( - project.dir, + pathname, )}`, ); } } } +async function cleanupProject( + project: StackProject, + environment: Pick< + CleanupEnvironment, + "removeProjectWithDocker" | "repairProjectPermissions" | "describeProjectPermissions" + >, +): Promise { + await cleanupOwnedPath(project.dir, project.cleanup, environment); +} + function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnapshot { const normalized = normalizeDir(projectDir); const managedStacksRoot = @@ -382,12 +397,17 @@ export function createStackE2eCleanupManager( return { registerHome(home) { - homes.set(normalizeDir(home.dir), home); + const dir = normalizeDir(home.dir); + homes.set(dir, { + dir, + dispose: () => home.dispose(), + }); }, registerStackProject(project) { - projects.set(normalizeDir(project.dir), { - dir: normalizeDir(project.dir), - cleanup: project.cleanup, + const dir = normalizeDir(project.dir); + projects.set(dir, { + dir, + cleanup: () => project.cleanup(), }); }, associateHome(projectDir, homeDir) { @@ -403,9 +423,14 @@ export function createStackE2eCleanupManager( homes.clear(); const failures: Array = []; + const associatedHomes = new Map(); for (const project of pendingProjects) { - const home = project.homeDir ? pendingHomes.get(project.homeDir) : undefined; + const homeDir = project.homeDir; + const home = homeDir === undefined ? undefined : pendingHomes.get(homeDir); + if (home !== undefined && homeDir !== undefined) { + associatedHomes.set(homeDir, home); + } const snapshot = environment.captureSnapshot(project.dir, project.homeDir); const hasRuntimeArtifacts = snapshot.documentFiles.length > 0 || @@ -439,18 +464,18 @@ export function createStackE2eCleanupManager( await cleanupProject(project, environment); } catch (error) { failures.push(cleanupErrorDetail(project.dir, error)); - } finally { - if (home !== undefined) { - try { - home.dispose(); - } catch (error) { - failures.push(cleanupErrorDetail(home.dir, error)); - } - } } } - // Cleanup of leaked stack projects is best-effort: assertions in the + for (const home of associatedHomes.values()) { + try { + await cleanupOwnedPath(home.dir, home.dispose, environment); + } catch (error) { + failures.push(cleanupErrorDetail(home.dir, error, "temp home")); + } + } + + // Cleanup of leaked stack resources is best-effort: assertions in the // test itself have already passed by the time `drain()` runs, and CI // runners are ephemeral so a leaked temp dir doesn't affect // correctness. Surface the details so developers can still see them @@ -461,7 +486,7 @@ export function createStackE2eCleanupManager( // sandbox). if (failures.length > 0) { console.warn( - `[stack-e2e-cleanup] ${failures.length} project(s) could not be cleaned up:\n${failures.join("\n")}`, + `[stack-e2e-cleanup] ${failures.length} resource(s) could not be cleaned up:\n${failures.join("\n")}`, ); } }, diff --git a/apps/cli/tests/live-global-setup.ts b/apps/cli/tests/live-global-setup.ts index d7584e757a..40b4d31c57 100644 --- a/apps/cli/tests/live-global-setup.ts +++ b/apps/cli/tests/live-global-setup.ts @@ -1,39 +1,32 @@ -// Import from the Vitest-free env module — globalSetup runs in a context where -// importing Vitest test APIs (which `helpers/live.ts` pulls in) is not valid. -import { isLiveConfigured, liveApiBaseUrl } from "./helpers/live-env.ts"; +import type { ProvidedContext } from "vitest"; -/** - * Global setup for the `live` Vitest project. When the live environment is not - * configured the suite is skipped (via `describeLive`) and this is a no-op. - * - * When it IS configured (the cli-e2e-ci runner sets `SUPABASE_ACCESS_TOKEN`), - * fail fast with a clear message if the platform is unreachable, so a - * misconfigured stack surfaces as a setup error rather than dozens of opaque - * per-test timeouts. - */ -export async function setup(): Promise { - if (!isLiveConfigured()) { - return; - } +import { makeApiClient } from "@supabase/api/effect"; +import { Effect } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; - // Reachability gate only. Any HTTP response — including 401/404 — proves the - // Management API is up and routing, which is all this probe needs to assert. - // supabox's mgmt-api requires auth on every route and exposes no public health - // endpoint (`/v1/health` 404s; an unauthenticated request is rejected by the - // auth middleware with 401), so we deliberately do NOT require a 2xx here. - // Functional and auth coverage is the live tests' job (e.g. `orgs list`). - const probeUrl = `${liveApiBaseUrl()}/v1/organizations`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - try { - await fetch(probeUrl, { signal: controller.signal }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `Live platform is not reachable at ${probeUrl}: ${reason}.\n` + - "Ensure the supabox stack is up and the host can reach mgmt-api (see cli-e2e-ci).", - ); - } finally { - clearTimeout(timeout); - } +import "./helpers/live-provided-context.ts"; +import { cleanupLiveEnvironment, provisionLiveEnvironment } from "./helpers/live-project.ts"; +import { liveAccessToken, liveApiUrl, validateLiveConfig } from "./helpers/live-env.ts"; + +type LiveSetupContext = { + provide: (key: K, value: ProvidedContext[K]) => void; +}; + +/** Provision one disposable project for the entire serial live Vitest run. */ +export async function setup({ provide }: LiveSetupContext): Promise<() => Promise> { + validateLiveConfig(); + const { api, environment } = await Effect.runPromise( + Effect.gen(function* () { + const api = yield* makeApiClient({ baseUrl: liveApiUrl(), accessToken: liveAccessToken() }); + const environment = yield* provisionLiveEnvironment(api); + return { api, environment }; + }).pipe(Effect.provide(FetchHttpClient.layer)), + ); + provide("liveProject", environment.project); + provide("liveProfilePath", environment.profilePath); + return async () => { + await Effect.runPromise(cleanupLiveEnvironment(api, environment)); + }; } + +export default setup; diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index a8c53dd4e5..fcfa681541 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -70,9 +70,9 @@ export default defineConfig({ { plugins: [dockerfileTextPlugin()], test: { - // Live tests run against a real platform (a supabox stack in CI) and - // are gated by `describeLive`, so they are inert unless the live env - // is configured. Never part of the default unit/integration/e2e loop. + // Live tests run against one provisioned project on the configured + // platform. They are never part of the default unit/integration/e2e + // loop; an explicit run fails fast when required configuration is absent. name: "live", include: ["**/*.live.test.ts"], fileParallelism: false, diff --git a/docs/adr/0013-live-e2e-bypasses-replay-server.md b/docs/adr/0013-live-e2e-bypasses-replay-server.md index 9b0c758818..570a35ce8d 100644 --- a/docs/adr/0013-live-e2e-bypasses-replay-server.md +++ b/docs/adr/0013-live-e2e-bypasses-replay-server.md @@ -3,131 +3,60 @@ **Status**: accepted **Date**: 2026-06-16 -## Problem Statement +## Problem -The CLI has no true end-to-end tests. `apps/cli-e2e` is a replay/record harness: -in **replay** mode it serves recorded HTTP fixtures (fast, deterministic, no -network); in **record** mode it proxies the CLI's Management API and Docker -traffic to staging only to *capture* those fixtures. Tests always assert against -replayed fixtures, never live responses. Behaviour that cannot be mocked — real -Management API calls and the real Docker bundler (e.g. `functions deploy`) — is -therefore untested. - -[CLI-1630](https://linear.app/supabase/issue/CLI-1630/set-up-proper-live-e2e-tests-for-the-cli) -adds a structured Vitest **live** suite that runs the real CLI against a real -backend (staging today, the dockerized `supabox` stack later) as a non-blocking -smoke test before a stable deploy. - -The open architectural question was *how* live mode should reach the backend. -The first instinct was to add a third runtime mode inside `replay-server.ts` -alongside `replay` and `record` — taking record mode's passthrough path -(CLI → replay server → real API) but skipping fixture I/O. That keeps the -existing Docker and storage proxies "for free." +`apps/cli-e2e` is a replay/record harness. Replay tests use recorded HTTP and +Docker fixtures, so they do not exercise a real Management API, project data +plane, or Docker bundler. The CLI needs a small, non-blocking golden-path suite +that crosses those boundaries for real. ## Decision -Live mode **does not route through the replay server**. It is a harness-wiring -mode, not a `replay-server.ts` branch. - -- Live tests reuse `createHarness`/`exec` from `@supabase/cli-test-helpers`, but - the harness is wired **directly**: `apiUrl = CLI_E2E_API_URL` (the real - Management API) and `DOCKER_HOST` points at the **real Docker socket**. -- `replay-server.ts` is untouched — no `live` branch, no live Docker or storage - proxy. -- Assertions are **outcome-based**, modeled on the manual deploy playbook: - 1. run the real CLI (`run([...])`) and assert `exitCode` / `stdout`; - 2. **invoke the deployed function over HTTP directly** and assert HTTP status + - the JSON body the function itself returns (e.g. `{case, ok:true}`). - The invoke is a direct HTTP call to `https://{ref}.{CLI_E2E_PROJECT_HOST}/functions/v1`, - not a proxied call — the replay server is nowhere in the assertion path. -- Because the assertion target is the function's own deterministic response (plus - exit codes / stdout substrings), the suite is **ID-agnostic** — no response - normalization or snapshot machinery by default. The function invoke URL and - anon key are resolved at setup from the freshly created project (anon key via - `GET /v1/projects/{ref}/api-keys`). - -The CLI target is a CI **matrix axis** (`CLI_HARNESS_TARGET`): each target runs -as its own job with `fail-fast: false`, so each implementation is independently -green/red. The pilot covers `go` (raw Go binary) and `ts-legacy` (the TS rewrite -that shells out to Go for most commands and runs native TS logic for ported -ones); `ts-next` is a later axis. - -## Rationale - -For the assertions live mode actually makes, intercepting the Management API buys -nothing — nothing inspects a proxied API body. The only thing the replay server -would do in live mode for `functions deploy` is relay Docker traffic -(CLI → relay → real socket) through its streaming/idle-timeout proxy. That -streaming relay is the most complex, most failure-prone code path in the harness, -and it would sit in front of the slowest, flakiest real operation (image pull + -bundle) for zero assertion benefit. Pointing `DOCKER_HOST` at the real socket -removes that failure surface entirely. - -Keeping `replay-server.ts` out of the live path also means live and record modes -stay decoupled: record mode's destructive fixture-tree rewrite, scenario logging, -and placeholder normalization never have to grow `isLive` guards, and a future -reader is not left wondering why a "transparent proxy" mode exists that records -nothing. - -The storage proxy (the other "free" proxy) is not exercised by the -`functions deploy` pilot, so it is not a reason to keep the server in front. If a -later live command genuinely needs host rewriting (e.g. storage on a different -host than the Management API), a scoped passthrough can be introduced *then* for -that command — YAGNI until a concrete need exists. - -The per-target matrix exists because `go` and `ts-legacy` are different code -paths reaching the same backend; running them as separate jobs gives two -independent green signals instead of one averaged result. +Live tests are collocated under `apps/cli` as `*.live.test.ts` and run directly +against a configured platform URL. They never route through the replay server. + +Local Docker-stack lifecycle tests are ordinary `*.e2e.test.ts` tests. They use +the existing e2e global setup and registered-stack cleanup and do not require a +platform token. A live test means the command under assertion reaches the +Management API, its provisioned project, or that project's data plane. Docker +is a runner prerequisite, including for live `functions deploy`; there is no +Docker-specific live fixture. + +The live global setup requires `SUPABASE_LIVE_API_URL` and +`SUPABASE_ACCESS_TOKEN`, then: + +1. Creates one uniquely named disposable project through the typed Effect + `@supabase/api` client pointed at the configured URL. +2. Waits for `ACTIVE_HEALTHY`, resolves API keys and pooler connection details, + creates a storage bucket, and derives the project host from the returned + database host. +3. Writes a temporary YAML profile containing the same API URL and project + host, and injects it into every CLI subprocess in the serial suite. +4. Deletes exactly that project and the temporary profile during teardown. + +`SUPABASE_LIVE_KEEP_PROJECT=1` skips project deletion for debugging but never +skips temporary profile cleanup. Provisioning failure attempts cleanup of the +exact project it created. + +All three supported targets—Supabox, a Docker-hosted API platform, and staging— +implement the same HTTP API contract. Retargeting a run only changes +`SUPABASE_LIVE_API_URL` and its access token. The live workflow keeps a Docker +preflight, one serial attempt, a 20-minute bound, and a scoped leftover-project +sweeper. + +`SUPABASE_LIVE_API_URL` configures the Management API only. Tenant data-plane +URLs continue to use the CLI profile contract, `https://.`; +`project_host` is derived from the provisioned project's typed database host. +This keeps tenant routing correct even when a local platform exposes its +Management API over plain HTTP. ## Consequences -### Positive - -- The live path has fewer moving parts: no proxy, no streaming relay, no fixture - guards. The Docker bundler talks to the real daemon as users' machines do. -- `replay-server.ts` and the replay/record contract are unchanged, so the - PR-blocking `e2e` suite is unaffected. -- Tests are trivial to add: drop a `deploy-e2e-foo` fixture function returning a - known body, add one `testLive` that runs deploy → invoke → asserts body. -- Retargeting from staging to `supabox` is genuinely an env swap - (`CLI_E2E_TARGET_ENV` + `CLI_E2E_API_URL` + `CLI_E2E_PROJECT_HOST` + token), - because assertions key off function output, not hostnames. - -### Negative - -- Live mode requires a working Docker daemon on the runner (enforced by a - `docker info` preflight) — unlike the replay suite, which served Docker - fixtures and needed no daemon. -- Each live run provisions and tears down a real staging project, so the suite is - inherently slower and subject to provisioning flake. Mitigated by a CI-level - re-run (up to 3×) rather than in-setup retry. -- A second wiring path now exists for the same harness (replay-via-server vs - live-direct); contributors must know which mode wires the CLI how. - -## Alternatives Considered - -1. **Third `live` branch inside `replay-server.ts`** (the initial plan): rejected. - It adds `isLive` guards throughout record-mode code, keeps the fragile Docker - stream relay in the hot path for no assertion benefit, and couples live mode to - machinery it does not use. -2. **Snapshot/normalization-first assertions**: rejected as the default. Outcome - assertions on function bodies are naturally ID-agnostic; a scoped normalizer is - added only if a future case makes CLI diagnostic output itself the assertion - target. -3. **Single CLI target**: rejected. `go` and `ts-legacy` are distinct - implementations of the same commands; one job would hide a regression in - whichever target was not chosen. -4. **One shared long-lived staging project**: rejected. State would leak between - runs and overlapping runs would collide; ephemeral per-job projects with - scoped teardown keep runs isolated. - -## Related Decisions - -- [Compiled Bun self-dispatch](../../packages/process-compose/docs/architecture.md#compiled-bun-self-dispatch): - the next CLI e2e harness runs against the compiled binary and therefore exercises its process - re-entry contract -- [ADR 0011](0011-cli-release-and-distribution-strategy.md): CLI Release & Distribution Strategy - -## See Also +The live path has no fixture proxy, host-rewrite layer, attached/managed mode, +ambient profile, project-ref gate, or capability-specific skip wrapper. The +single extended Vitest fixture is imported as `test` from +`apps/cli/tests/helpers/live.ts`; its context exposes `cli`, `project`, and an +isolated workspace. Setup and teardown may invoke other commands, but each +assertion stays focused on one command. -- [cli-e2e harness](../../apps/cli-e2e/AGENTS.md) +Replay/record behavior and fixtures in `apps/cli-e2e` remain unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index c5ab90ea00..c0bc1cf7b5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,23 +41,23 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi ## ADR index -| ID | Title | Status | -| ---- | ------------------------------------------------------------------------------------------ | -------- | -| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | -| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | -| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | -| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | -| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | -| 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | -| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | -| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | -| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | -| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | -| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | -| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | -| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded | -| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | -| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted | +| ID | Title | Status | +| ---- | -------------------------------------------------------------------------------------------------------------- | ---------- | +| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | +| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | +| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | +| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | +| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | +| 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | +| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | +| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | +| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | +| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | +| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | accepted | +| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded | +| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | +| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted | ## Template From b9254df4197ebba26bc16dfc69579ad2708fc7d1 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:16:04 +0000 Subject: [PATCH 31/63] ci(cli): publish ts docs reference (#6253) ## TL;DR fixes the CLI docs reference being frozen at v2.98.2 which was caused by the monorepo merge deleting the Go bumpdoc docs job from the release workflow, and is now fixed by a stable-only docs job that regenerates the spec from the TS command tree and delivers it to supabase/supabase the same way bumpdoc did... ## whats introduced? - a `docs` job in release.yml: stable releases only, runs after publish, non-blocking for the release, and mints the same scoped App token... - `scripts/publish-docs-spec.ts`: reads the generated spec on stdin, validates it (clispec 001, version match, command count) before any network action, formats it with a pinned prettier to match the published file's style, then updates the `cli/ref-doc` branch in supabase/supabase and opens a PR when none is open.. - bumpdoc's append semantics: when `cli/ref-doc` already exists the new spec lands as a commit on top, so fixes pushed onto an open bot PR survive later releases - 6 integration tests for the publisher's refusal and dry-run paths, plus a README release section describing the automated flow ## how? stable release now runs `generate-docs-spec.ts "$VERSION" | publish-docs-spec.ts --version "$VERSION"` which lands a validated, formatted spec as a commit on `cli/ref-doc` and opens the familiar `chore: update cli reference doc` PR for the docs team to merge - (same shape as supabase/supabase#45622) a stable dry run exercises the same pipe with `--dry-run`, which also verifies the App token mint without pushing anything up.. > [!NOTE] > will follow up on `supabase/supabase` with a spec-to-sidebar coverage guard, so a new command missing a `common-cli-sections.json` entry fails the bot PR instead of silently publishing no page... ## ref: - follow up to: https://github.com/supabase/cli/pull/6157 --- .github/workflows/release.yml | 55 ++++++ apps/cli/docs/README.md | 21 ++- apps/cli/package.json | 2 + .../cli/scripts/publish-docs-spec.e2e.test.ts | 80 +++++++++ apps/cli/scripts/publish-docs-spec.ts | 166 ++++++++++++++++++ pnpm-lock.yaml | 10 ++ 6 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 apps/cli/scripts/publish-docs-spec.e2e.test.ts create mode 100644 apps/cli/scripts/publish-docs-spec.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4140f2880c..9b90167e91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -228,6 +228,61 @@ jobs: LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} + # Republishes the supabase.com CLI reference, restoring the job the Go + # `tools/bumpdoc` ran before the monorepo merge (71b543255) deleted it — the + # published spec has been frozen at 2.98.2 since. Stable only: the reference + # documents the CLI users actually install, so pre-releases are skipped. + # Nothing depends on this job, so a docs-site failure cannot affect the + # already-completed release. + docs: + name: Publish reference docs + needs: [plan, release] + if: needs.plan.outputs.channel == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 15 + continue-on-error: true + steps: + # Scoped to supabase/supabase: this job pushes a branch and opens a PR + # there, and needs nothing beyond a plain read-only checkout here. + - id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + supabase + permission-contents: write + permission-pull-requests: write + - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 + with: + persist-credentials: false + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + - name: Authenticate git for the docs push + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: gh auth setup-git + - name: Publish the CLI reference + working-directory: apps/cli + # `shell: bash` for `-o pipefail`: the default `run:` shell would take + # the exit status of the publisher alone, so a generator that died + # part-way would look like a successful run of a truncated spec. + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ needs.plan.outputs.version }} + DRY_RUN: ${{ needs.plan.outputs.dry_run }} + run: | + args=() + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + bun scripts/generate-docs-spec.ts "$VERSION" \ + | bun scripts/publish-docs-spec.ts --version "$VERSION" "${args[@]}" + # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit # success() gate, so this only runs when both plan and release succeeded. diff --git a/apps/cli/docs/README.md b/apps/cli/docs/README.md index fdc021a9f2..c8b7f33ae7 100644 --- a/apps/cli/docs/README.md +++ b/apps/cli/docs/README.md @@ -13,16 +13,25 @@ bun scripts/generate-docs-spec.ts > cli_v1_commands.yaml ## Release -1. Clone the [supabase/supabase](https://github.com/supabase/supabase) repo -2. Copy over the CLI reference and reformat +The `docs` job in `.github/workflows/release.yml` publishes the reference on every stable +release: it pipes the generator into `scripts/publish-docs-spec.ts`, which formats the spec, +pushes the `cli/ref-doc` branch in [supabase/supabase](https://github.com/supabase/supabase), +and opens a PR when none is open. When the spec is already published and a PR is open +or not needed, the run is a no-op. +Later releases add commits on top of an open `cli/ref-doc` PR instead of rewriting it, so +fixes committed onto the branch survive. + +New commands also need an entry in +[common-cli-sections.json](https://github.com/supabase/supabase/blob/master/apps/docs/spec/common-cli-sections.json) +— the sidebar decides which pages exist, and a command without an entry is silently +dropped from the docs site. + +To publish by hand, run the same pipe the job runs: ```bash -mv ../cli/apps/cli/cli_v1_commands.yaml apps/docs/spec/ -npx prettier -w apps/docs/spec/cli_v1_commands.yaml +bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--dry-run] ``` -3. If there are new commands added, update [common-cli-sections.json](https://github.com/supabase/supabase/blob/master/apps/docs/spec/common-cli-sections.json) manually - ## Maintenance When adding or changing a command or flag, update the matching entries in diff --git a/apps/cli/package.json b/apps/cli/package.json index 69df5ea063..907b71eaf7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -79,6 +79,7 @@ "pg": "^8.23.0", "pg-copy-streams": "^7.0.0", "posthog-node": "^5.49.1", + "prettier": "3.8.1", "react": "^19.2.8", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", @@ -140,6 +141,7 @@ "oxfmt", "oxlint", "oxlint-tsgolint", + "prettier", "semantic-release", "@anthropic-ai/claude-agent-sdk", "@anthropic-ai/sdk", diff --git a/apps/cli/scripts/publish-docs-spec.e2e.test.ts b/apps/cli/scripts/publish-docs-spec.e2e.test.ts new file mode 100644 index 0000000000..819c6b09d4 --- /dev/null +++ b/apps/cli/scripts/publish-docs-spec.e2e.test.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { stringify } from "yaml"; + +const cliRoot = path.resolve(import.meta.dirname, ".."); + +function runScript( + args: ReadonlyArray, + stdin: string, +): { exitCode: number; stdout: string; stderr: string } { + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...environment } = process.env; + const result = Bun.spawnSync(["bun", "scripts/publish-docs-spec.ts", ...args], { + cwd: cliRoot, + env: environment, + stdin: Buffer.from(stdin), + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +function validSpec(version: string): string { + return stringify({ + clispec: "001", + info: { id: "cli", version, title: "Supabase CLI" }, + commands: Array.from({ length: 144 }, (_, index) => ({ id: `supabase-command-${index}` })), + }); +} + +describe("publish-docs-spec.ts entrypoint", () => { + it("prints usage and fails without --version", () => { + const { exitCode, stderr } = runScript(["--dry-run"], validSpec("1.0.0")); + expect(exitCode).toBe(1); + expect(stderr).toContain("Usage:"); + }, 30_000); + + it("refuses an empty spec", () => { + const { exitCode, stderr } = runScript(["--version", "1.0.0", "--dry-run"], ""); + expect(exitCode).toBe(1); + expect(stderr).toContain("Refusing to publish an empty spec"); + }, 30_000); + + it("refuses a spec that is not valid clispec YAML", () => { + const { exitCode, stderr } = runScript( + ["--version", "1.0.0", "--dry-run"], + "clispec: [unclosed", + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Refusing to publish"); + }, 30_000); + + it("refuses a truncated spec with too few commands", () => { + const truncated = stringify({ + clispec: "001", + info: { id: "cli", version: "1.0.0", title: "Supabase CLI" }, + commands: [{ id: "supabase-init" }], + }); + const { exitCode, stderr } = runScript(["--version", "1.0.0", "--dry-run"], truncated); + expect(exitCode).toBe(1); + expect(stderr).toContain("expected at least 144"); + }, 30_000); + + it("refuses a spec whose version does not match --version", () => { + const { exitCode, stderr } = runScript(["--version", "1.0.0", "--dry-run"], validSpec("2.0.0")); + expect(exitCode).toBe(1); + expect(stderr).toContain("does not match --version 1.0.0"); + }, 30_000); + + it("summarizes a valid spec in dry-run mode without publishing", () => { + const spec = validSpec("1.0.0"); + const { exitCode, stdout, stderr } = runScript(["--version", "1.0.0", "--dry-run"], spec); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe( + `Would publish ${spec.length} bytes to supabase/supabase:apps/docs/spec/cli_v1_commands.yaml on branch cli/ref-doc`, + ); + }, 30_000); +}); diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts new file mode 100644 index 0000000000..b4b969500f --- /dev/null +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -0,0 +1,166 @@ +/** + * Publishes the generated CLI reference to the docs site by opening a PR + * against supabase/supabase, replacing the Go `tools/bumpdoc` that was deleted + * in the monorepo merge (71b543255). The reference has not been republished + * since, so the published spec is frozen at 2.98.2. + * + * Reads the spec on stdin so it composes with the generator exactly the way the + * Go release job did: + * + * bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version + * + * Like `bumpdoc`, this is a no-op when the spec is already published and no PR + * is missing: it prints "already up to date" and exits 0. A branch whose spec + * is ahead of base still gets its PR ensured, so a run that pushed but failed + * to open the PR is repaired by the next release. Any real failure exits + * non-zero. + */ +import { $ } from "bun"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; +import { parse } from "yaml"; + +const { values } = parseArgs({ + options: { + version: { type: "string" }, + repo: { type: "string", default: "supabase/supabase" }, + // Path of the spec inside the docs repo, as the Go job passed it. + "spec-path": { type: "string", default: "apps/docs/spec/cli_v1_commands.yaml" }, + branch: { type: "string", default: "cli/ref-doc" }, + base: { type: "string", default: "master" }, + "dry-run": { type: "boolean", default: false }, + }, +}); + +const versionArgument = values.version; +if (!versionArgument) { + console.error( + "Usage: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--repo ] [--spec-path ] [--branch ] [--base ] [--dry-run]", + ); + process.exit(1); +} +const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; + +const repo = values.repo!; +const specPath = values["spec-path"]!; +const branch = values.branch!; +const base = values.base!; +const dryRun = values["dry-run"]!; + +const spec = await Bun.stdin.text(); +if (spec.trim().length === 0) { + console.error("Refusing to publish an empty spec: nothing was piped in on stdin."); + process.exit(1); +} + +let parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknown }; +try { + parsed = parse(spec); +} catch (error) { + console.error(`Refusing to publish: stdin is not valid YAML (${error}).`); + process.exit(1); +} +if (parsed?.clispec !== "001") { + console.error( + `Refusing to publish: expected clispec "001", got ${JSON.stringify(parsed?.clispec)}.`, + ); + process.exit(1); +} +if (parsed.info?.version !== version) { + console.error( + `Refusing to publish: the spec's version ${JSON.stringify(parsed.info?.version)} does not match --version ${version}.`, + ); + process.exit(1); +} +const commands = parsed.commands; +if (!Array.isArray(commands) || commands.length < 144) { + console.error( + `Refusing to publish: the spec has ${Array.isArray(commands) ? commands.length : 0} commands, expected at least 144.`, + ); + process.exit(1); +} + +if (dryRun) { + console.log(`Would publish ${spec.length} bytes to ${repo}:${specPath} on branch ${branch}`); + process.exit(0); +} + +// `--depth 1` is enough: when `cli/ref-doc` already exists its tip is fetched +// and the new spec lands as one commit on top, so commits pushed onto an open +// PR (sidebar fixes) survive later releases; otherwise the branch starts from +// base. +const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-docs-")); +try { + await $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`; + + const remoteBranch = await $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}` + .nothrow() + .quiet(); + if (remoteBranch.exitCode === 0) { + await $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`; + } else { + await $`git -C ${tmpDir} checkout --quiet -B ${branch}`; + } + + const target = path.join(tmpDir, specPath); + await writeFile(target, spec); + const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); + await $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`; + + const message = "chore: update cli reference doc"; + await $`git -C ${tmpDir} add ${specPath}`; + const unchanged = await $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}` + .nothrow() + .quiet(); + if (unchanged.exitCode === 0) { + console.log( + remoteBranch.exitCode === 0 + ? `${specPath} is already up to date on ${branch}` + : `${specPath} is already up to date in ${repo}`, + ); + } else { + await $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`; + await $`git -C ${tmpDir} push --quiet origin ${branch}`; + console.log(`Pushed ${branch} to ${repo}`); + } + + const unpublished = await $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${specPath}` + .nothrow() + .quiet(); + if (unpublished.exitCode === 1) { + const existing = + await $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` + .cwd(tmpDir) + .text(); + const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); + const ownPulls = openPulls.filter( + (pull) => + pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + ); + if (ownPulls.length > 0) { + console.log(`Reusing the open PR for ${branch}`); + } else { + const body = [ + "Updates the CLI reference from the supabase/cli release workflow.", + "", + "Generated by `scripts/generate-docs-spec.ts` in supabase/cli — edit the", + "command tree or the content under `apps/cli/docs/` there rather than this", + "file.", + "", + "New commands also need an entry in `apps/docs/spec/common-cli-sections.json` —", + "without one a command's page is silently dropped from the docs site.", + "Sections fixes can be committed directly onto this branch — later releases", + "add commits on top instead of rewriting it.", + ].join("\n"); + await $`gh pr create --repo ${repo} --title ${message} --body ${body} --base ${base} --head ${branch}`.cwd( + tmpDir, + ); + console.log(`Opened a pull request against ${repo}`); + } + } +} finally { + await rm(tmpDir, { recursive: true, force: true }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58d91cf5dd..2664cd0f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: posthog-node: specifier: ^5.49.1 version: 5.49.1 + prettier: + specifier: 3.8.1 + version: 3.8.1 react: specifier: ^19.2.8 version: 19.2.8 @@ -5855,6 +5858,11 @@ packages: rxjs: optional: true + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -12361,6 +12369,8 @@ snapshots: dependencies: '@posthog/core': 1.48.1 + prettier@3.8.1: {} + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 From 464692f135950720b6aef1173cbb54ce71af2d73 Mon Sep 17 00:00:00 2001 From: kanad Date: Mon, 24 Aug 2026 14:06:44 +0000 Subject: [PATCH 32/63] feat(config): expose config defaults and provide sparse mapping functions (#6205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements [CLI-2155](https://linear.app/supabase/issue/CLI-2155): a stored reference of config default values and a mapping function that omits values matching them, so `config diff` (CLI-2156) and `config pull` (CLI-2064) can compare sparse configs instead of full effective ones. ## What changed **New API in `@supabase/config`** (`packages/config/src/sparse.ts`, all pure and synchronous, operating on decoded `ProjectConfig` values): - `getDefaultProjectConfig()` — the default config, derived by decoding `{}` through `ProjectConfigSchema` (memoized). The schema's existing `default` annotations and decoding defaults remain the single source of truth; there is no hand-maintained defaults table to drift. - `subtractProjectConfig(config, baseline)` — returns the sparse config `config − baseline`: strict deep equality (order-sensitive arrays), sections emptied by subtraction dropped recursively. Directional, so CLI-2156 can subtract a remote block against the merged base config. - `omitDefaultValues(config)` — `subtractProjectConfig` with the default config as baseline. The result is itself a valid config document: re-decoding refills the removed defaults, yielding the same effective config under the current schema's defaults. `[remotes.*]` blocks (config overrides for a specific persistent Supabase branch, bound by `project_id`) pass through untouched — pruning them against global defaults would silently change what a branch resolves to. **Refactor:** `io.ts` had a private `stripDefaults`/`isEqualValue` walk used for writing minimal config files; that walk is now the shared `subtractValue` core in `sparse.ts`, consumed by both the new API and the save path, so there is one subtraction implementation in the package. **Design docs:** ADR 0018 (`docs/adr/0018-sparse-config-subtraction.md`) records the decision, the remote-block baseline rule, what the output is depending on the baseline (defaults → a valid, effectively-equivalent config document whose meaning leans on the current defaults version; any other baseline → an overlay meaningful only relative to that baseline), and defines the working vocabulary inline (default config, sparse config, subtract, base config, remote block, drift). ## Reviewer notes - The scope split with CLI-2156 is deliberate: the merge cascade and the Management-API-response→config shape translation land with the diff core, keeping this package free of `packages/api` types. See the [ticket comment](https://linear.app/supabase/issue/CLI-2155/store-the-config-default-values-and-provide-mapping-function) and ADR 0018's Alternatives. - `subtractProjectConfig` uses an overload with an untyped implementation signature rather than an `as` cast — TypeScript can't verify that a structural walk over `unknown` reconstructs a `DeepPartial` of its input; the unit tests pin the contract. - A sparse file's effective meaning leans on the schema's defaults, so a default changed in a future schema version changes what the file denotes — this is the PRFAQ's versioned-defaults open question, deliberately out of scope here per ADR 0018. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 Co-authored-by: Colum Ferry --- docs/adr/0018-sparse-config-subtraction.md | 66 ++++++ docs/adr/README.md | 1 + packages/config/src/index.ts | 7 + packages/config/src/io.ts | 92 +++------ packages/config/src/sparse.ts | 229 +++++++++++++++++++++ packages/config/src/sparse.unit.test.ts | 172 ++++++++++++++++ 6 files changed, 499 insertions(+), 68 deletions(-) create mode 100644 docs/adr/0018-sparse-config-subtraction.md create mode 100644 packages/config/src/sparse.ts create mode 100644 packages/config/src/sparse.unit.test.ts diff --git a/docs/adr/0018-sparse-config-subtraction.md b/docs/adr/0018-sparse-config-subtraction.md new file mode 100644 index 0000000000..0bd3e91062 --- /dev/null +++ b/docs/adr/0018-sparse-config-subtraction.md @@ -0,0 +1,66 @@ +# 0018. Sparse Config Subtraction + +**Status**: proposed +**Date**: 2026-08-18 + +## Problem Statement + +`config diff` (CLI-2156) and `config pull` (CLI-2064) compare a project's remote configuration against the local `config.toml` to surface *drift*: any difference between the project's effective remote configuration and the local file. The remote endpoint (`GET /v2/projects/{ref}/config`) returns the *effective* config — every setting reported, defaulted or not — and a locally decoded `ProjectConfig` likewise has every default filled in. Comparing these full objects directly would drown the user in hundreds of identical default values. CLI-2155 asks for a stored reference of config defaults and a mapping function that omits values matching them, so diffs stay readable and pulled files stay sparse. + +The trap is where that mapping recurses. A `[remotes.